diff --git a/db/schema_phase16_invoice_upgrade.sql b/db/schema_phase16_invoice_upgrade.sql new file mode 100644 index 0000000..5a6df35 --- /dev/null +++ b/db/schema_phase16_invoice_upgrade.sql @@ -0,0 +1,143 @@ +-- ============================================================================ +-- 优匠优服 · Phase 16 开票体系全链路升级(抬头管理 + 个人/企业普专票 + 订单核销 + 电子票交付) +-- 1. 新建发票抬头管理表 fa_invoice_title(支持个人/非企业、企业单位、普票/增值税专票资质) +-- 2. 扩展开票申请表 fa_invoice(补充专票六要素、申请单号、关联订单集、电子发票文件、撤回状态) +-- 3. 扩展订单表 fa_order(增加 invoice_id 与 invoice_status,支持按订单勾选合并开票与防重复开票) +-- 4. 补齐后台「财务资金」菜单下的「发票抬头」查看菜单与开票详情权限节点 +-- ============================================================================ +SET NAMES utf8mb4; + +-- 1. 创建发票抬头管理表 fa_invoice_title +CREATE TABLE IF NOT EXISTS `fa_invoice_title` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `user_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '所属用户ID', + `merchant_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '关联商家ID(0为个人维护)', + `title_type` enum('personal','enterprise') NOT NULL DEFAULT 'personal' COMMENT '抬头类型:personal=个人/非企业,enterprise=企业单位', + `default_invoice_type` enum('normal','special') NOT NULL DEFAULT 'normal' COMMENT '默认发票类型:normal=普通发票,special=专用发票', + `title` varchar(100) NOT NULL DEFAULT '' COMMENT '发票抬头名称', + `tax_no` varchar(50) NOT NULL DEFAULT '' COMMENT '纳税人识别号/统一社会信用代码', + `bank_name` varchar(100) NOT NULL DEFAULT '' COMMENT '开户银行名称', + `bank_account` varchar(50) NOT NULL DEFAULT '' COMMENT '企业银行账号', + `reg_address` varchar(255) NOT NULL DEFAULT '' COMMENT '企业注册地址', + `reg_mobile` varchar(30) NOT NULL DEFAULT '' COMMENT '企业注册电话', + `email` varchar(100) NOT NULL DEFAULT '' COMMENT '默认接收邮箱', + `receive_mobile` varchar(20) NOT NULL DEFAULT '' COMMENT '默认通知手机号', + `is_default` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否默认抬头:1=是,0=否', + `createtime` bigint(16) DEFAULT NULL COMMENT '创建时间', + `updatetime` bigint(16) DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`) USING BTREE, + KEY `idx_user_default` (`user_id`,`is_default`) USING BTREE, + KEY `idx_merchant` (`merchant_id`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='发票抬头管理'; + +-- 2. 幂等扩展 fa_invoice 与 fa_order 表字段 +DROP PROCEDURE IF EXISTS `sp_phase16_invoice_upgrade`; +DELIMITER $$ +CREATE PROCEDURE `sp_phase16_invoice_upgrade`() +BEGIN + DECLARE db_name VARCHAR(64); + SET db_name = DATABASE(); + + -- 扩展 fa_invoice.status 枚举支持 cancel(已撤回) + ALTER TABLE `fa_invoice` + MODIFY COLUMN `status` enum('wait','issued','reject','cancel') NOT NULL DEFAULT 'wait' COMMENT '处理状态:wait=待开具,issued=已开票,reject=已驳回,cancel=已撤回'; + + IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = db_name AND TABLE_NAME = 'fa_invoice' AND COLUMN_NAME = 'invoice_sn') THEN + ALTER TABLE `fa_invoice` ADD COLUMN `invoice_sn` varchar(32) NOT NULL DEFAULT '' COMMENT '开票申请单号' AFTER `id`; + END IF; + + IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = db_name AND TABLE_NAME = 'fa_invoice' AND COLUMN_NAME = 'title_id') THEN + ALTER TABLE `fa_invoice` ADD COLUMN `title_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '关联抬头ID' AFTER `user_id`; + END IF; + + IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = db_name AND TABLE_NAME = 'fa_invoice' AND COLUMN_NAME = 'title_type') THEN + ALTER TABLE `fa_invoice` ADD COLUMN `title_type` enum('personal','enterprise') NOT NULL DEFAULT 'personal' COMMENT '抬头类型:personal=个人/非企业,enterprise=企业单位' AFTER `type`; + END IF; + + IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = db_name AND TABLE_NAME = 'fa_invoice' AND COLUMN_NAME = 'bank_name') THEN + ALTER TABLE `fa_invoice` ADD COLUMN `bank_name` varchar(100) NOT NULL DEFAULT '' COMMENT '开户银行' AFTER `tax_no`; + END IF; + + IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = db_name AND TABLE_NAME = 'fa_invoice' AND COLUMN_NAME = 'bank_account') THEN + ALTER TABLE `fa_invoice` ADD COLUMN `bank_account` varchar(50) NOT NULL DEFAULT '' COMMENT '企业银行账号' AFTER `bank_name`; + END IF; + + IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = db_name AND TABLE_NAME = 'fa_invoice' AND COLUMN_NAME = 'reg_address') THEN + ALTER TABLE `fa_invoice` ADD COLUMN `reg_address` varchar(255) NOT NULL DEFAULT '' COMMENT '企业注册地址' AFTER `bank_account`; + END IF; + + IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = db_name AND TABLE_NAME = 'fa_invoice' AND COLUMN_NAME = 'reg_mobile') THEN + ALTER TABLE `fa_invoice` ADD COLUMN `reg_mobile` varchar(30) NOT NULL DEFAULT '' COMMENT '企业注册电话' AFTER `reg_address`; + END IF; + + IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = db_name AND TABLE_NAME = 'fa_invoice' AND COLUMN_NAME = 'receive_mobile') THEN + ALTER TABLE `fa_invoice` ADD COLUMN `receive_mobile` varchar(20) NOT NULL DEFAULT '' COMMENT '接收通知手机号' AFTER `email`; + END IF; + + IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = db_name AND TABLE_NAME = 'fa_invoice' AND COLUMN_NAME = 'order_ids') THEN + ALTER TABLE `fa_invoice` ADD COLUMN `order_ids` varchar(1000) NOT NULL DEFAULT '' COMMENT '关联订单ID集合(逗号分隔)' AFTER `remark`; + END IF; + + IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = db_name AND TABLE_NAME = 'fa_invoice' AND COLUMN_NAME = 'order_count') THEN + ALTER TABLE `fa_invoice` ADD COLUMN `order_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '关联订单笔数' AFTER `order_ids`; + END IF; + + IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = db_name AND TABLE_NAME = 'fa_invoice' AND COLUMN_NAME = 'invoice_code') THEN + ALTER TABLE `fa_invoice` ADD COLUMN `invoice_code` varchar(50) NOT NULL DEFAULT '' COMMENT '发票代码' AFTER `status`; + END IF; + + IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = db_name AND TABLE_NAME = 'fa_invoice' AND COLUMN_NAME = 'invoice_file') THEN + ALTER TABLE `fa_invoice` ADD COLUMN `invoice_file` varchar(500) NOT NULL DEFAULT '' COMMENT '电子发票文件URL(PDF/OFD/图片)' AFTER `invoice_no`; + END IF; + + IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = db_name AND TABLE_NAME = 'fa_invoice' AND COLUMN_NAME = 'issue_time') THEN + ALTER TABLE `fa_invoice` ADD COLUMN `issue_time` bigint(16) DEFAULT NULL COMMENT '开票完成时间' AFTER `audit_msg`; + END IF; + + IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = db_name AND TABLE_NAME = 'fa_invoice' AND INDEX_NAME = 'idx_user_status') THEN + ALTER TABLE `fa_invoice` ADD INDEX `idx_user_status` (`user_id`, `status`); + END IF; + + -- 扩展 fa_order 开票锁定字段 + IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = db_name AND TABLE_NAME = 'fa_order' AND COLUMN_NAME = 'invoice_id') THEN + ALTER TABLE `fa_order` ADD COLUMN `invoice_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '关联开票申请ID'; + END IF; + + IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = db_name AND TABLE_NAME = 'fa_order' AND COLUMN_NAME = 'invoice_status') THEN + ALTER TABLE `fa_order` ADD COLUMN `invoice_status` enum('none','wait','issued') NOT NULL DEFAULT 'none' COMMENT '开票状态:none=未开票,wait=开票中,issued=已开票'; + END IF; + + IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = db_name AND TABLE_NAME = 'fa_order' AND INDEX_NAME = 'idx_user_pay_inv') THEN + ALTER TABLE `fa_order` ADD INDEX `idx_user_pay_inv` (`user_id`, `pay_status`, `invoice_status`); + END IF; +END$$ +DELIMITER ; + +CALL `sp_phase16_invoice_upgrade`(); +DROP PROCEDURE IF EXISTS `sp_phase16_invoice_upgrade`; + +-- 3. 回填历史开票数据的抬头类型与申请单号 +UPDATE `fa_invoice` SET `title_type` = 'enterprise' WHERE `tax_no` <> '' AND `title_type` = 'personal'; +UPDATE `fa_invoice` SET `invoice_sn` = CONCAT('INV', DATE_FORMAT(FROM_UNIXTIME(IFNULL(`createtime`, UNIX_TIMESTAMP())), '%Y%m%d'), LPAD(`id`, 6, '0')) WHERE `invoice_sn` = ''; +UPDATE `fa_invoice` SET `issue_time` = `updatetime` WHERE `status` = 'issued' AND `issue_time` IS NULL; + +-- 4. 补齐后台菜单与权限节点(财务资金 -> 开票申请 / 发票抬头) +INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`) +SELECT 'file', r.id, 'invoice/detail', '详情', 'fa fa-circle-o', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal' +FROM `fa_auth_rule` r WHERE r.name = 'invoice' +AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'invoice/detail'); + +INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`) +SELECT 'file', p.id, 'invoice_title', '发票抬头', 'fa fa-address-card-o', '', '个人业主与企业商家维护的发票抬头及专票资质档案', 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 150, 'normal' +FROM `fa_auth_rule` p WHERE p.name = 'finance_manage' +AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'invoice_title'); + +INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`) +SELECT 'file', r.id, 'invoice_title/index', '查看', 'fa fa-circle-o', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal' +FROM `fa_auth_rule` r WHERE r.name = 'invoice_title' +AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'invoice_title/index'); + +INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`) +SELECT 'file', r.id, 'invoice_title/detail', '详情', 'fa fa-circle-o', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal' +FROM `fa_auth_rule` r WHERE r.name = 'invoice_title' +AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'invoice_title/detail'); diff --git a/db/schema_phase17_admin_optimize.sql b/db/schema_phase17_admin_optimize.sql new file mode 100644 index 0000000..b211d96 --- /dev/null +++ b/db/schema_phase17_admin_optimize.sql @@ -0,0 +1,120 @@ +-- Phase 17: 管理后台全量巡检优化与菜单权限补齐(幂等可重复执行) +SET NAMES utf8mb4; + +-- 1. 收敛左侧导航栏冗余/高危菜单(微信公众号遗留模块与在线插件装卸入口从导航隐藏) +UPDATE `fa_auth_rule` SET `ismenu` = 0 WHERE `name` IN ('wechat', 'addon'); +UPDATE `fa_auth_rule` SET `ismenu` = 0 WHERE `name` LIKE 'wechat/%'; + +-- 2. 订单调度中心:新增「业主晒单」菜单与子权限 +SET @order_manage_id = (SELECT `id` FROM `fa_auth_rule` WHERE `name` = 'order_manage' LIMIT 1); + +INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `url`, `condition`, `remark`, `ismenu`, `menutype`, `extend`, `py`, `pinyin`, `createtime`, `updatetime`, `weigh`, `status`) +SELECT 'file', @order_manage_id, 'showcase', '业主晒单', 'fa fa-camera', '', '', '管理小程序首页及晒单广场公开展示的业主完工实拍', 1, 'addtabs', '', 'yzsd', 'yezhushaidan', UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 189, 'normal' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'showcase'); + +SET @showcase_id = (SELECT `id` FROM `fa_auth_rule` WHERE `name` = 'showcase' LIMIT 1); + +INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`) +SELECT 'file', @showcase_id, 'showcase/index', '查看', 'fa fa-circle-o', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'showcase/index'); + +INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`) +SELECT 'file', @showcase_id, 'showcase/detail', '详情', 'fa fa-circle-o', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'showcase/detail'); + +INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`) +SELECT 'file', @showcase_id, 'showcase/edit', '治理审核', 'fa fa-circle-o', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'showcase/edit'); + +INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`) +SELECT 'file', @showcase_id, 'showcase/multi', '批量更新', 'fa fa-circle-o', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'showcase/multi'); + +-- 3. 财务资金中心:新增「资金流水」总账菜单与子权限 +SET @finance_manage_id = (SELECT `id` FROM `fa_auth_rule` WHERE `name` = 'finance_manage' LIMIT 1); + +INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `url`, `condition`, `remark`, `ismenu`, `menutype`, `extend`, `py`, `pinyin`, `createtime`, `updatetime`, `weigh`, `status`) +SELECT 'file', @finance_manage_id, 'wallet_log', '资金流水', 'fa fa-exchange', '', '', '师傅与用户钱包收支变动不可篡改审计总账', 1, 'addtabs', '', 'zjls', 'zijinliushui', UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 154, 'normal' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'wallet_log'); + +SET @wallet_log_id = (SELECT `id` FROM `fa_auth_rule` WHERE `name` = 'wallet_log' LIMIT 1); + +INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`) +SELECT 'file', @wallet_log_id, 'wallet_log/index', '查看', 'fa fa-circle-o', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'wallet_log/index'); + +INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`) +SELECT 'file', @wallet_log_id, 'wallet_log/detail', '凭证详情', 'fa fa-circle-o', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'wallet_log/detail'); + +-- 4. 补齐发票抬头及各模块新增的详情/派单子权限规则(便于子角色精细化授权) +SET @invoice_title_id = (SELECT `id` FROM `fa_auth_rule` WHERE `name` = 'invoice_title' LIMIT 1); +INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`) +SELECT 'file', @invoice_title_id, 'invoice_title/index', '查看', 'fa fa-circle-o', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal' +FROM DUAL WHERE @invoice_title_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'invoice_title/index'); + +INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`) +SELECT 'file', @invoice_title_id, 'invoice_title/detail', '详情', 'fa fa-circle-o', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal' +FROM DUAL WHERE @invoice_title_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'invoice_title/detail'); + +INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`) +SELECT 'file', @invoice_title_id, 'invoice_title/edit', '订正', 'fa fa-circle-o', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal' +FROM DUAL WHERE @invoice_title_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'invoice_title/edit'); + +INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`) +SELECT 'file', @invoice_title_id, 'invoice_title/del', '删除', 'fa fa-circle-o', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal' +FROM DUAL WHERE @invoice_title_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'invoice_title/del'); + +SET @order_id = (SELECT `id` FROM `fa_auth_rule` WHERE `name` = 'order' LIMIT 1); +INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`) +SELECT 'file', @order_id, 'order/dispatch', '人工派单/改派', 'fa fa-circle-o', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal' +FROM DUAL WHERE @order_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'order/dispatch'); + +SET @service_id = (SELECT `id` FROM `fa_auth_rule` WHERE `name` = 'service' LIMIT 1); +INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`) +SELECT 'file', @service_id, 'service/detail', '商品详情', 'fa fa-circle-o', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal' +FROM DUAL WHERE @service_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'service/detail'); + +SET @user_id = (SELECT `id` FROM `fa_auth_rule` WHERE `name` = 'user/user' LIMIT 1); +INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`) +SELECT 'file', @user_id, 'user/user/detail', '客户全景详情', 'fa fa-circle-o', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal' +FROM DUAL WHERE @user_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'user/user/detail'); + +SET @appeal_id = (SELECT `id` FROM `fa_auth_rule` WHERE `name` = 'appeal' LIMIT 1); +INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`) +SELECT 'file', @appeal_id, 'appeal/detail', '申诉详情', 'fa fa-circle-o', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal' +FROM DUAL WHERE @appeal_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'appeal/detail'); + +SET @review_id = (SELECT `id` FROM `fa_auth_rule` WHERE `name` = 'review' LIMIT 1); +INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`) +SELECT 'file', @review_id, 'review/detail', '评价详情', 'fa fa-circle-o', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal' +FROM DUAL WHERE @review_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'review/detail'); + +SET @settle_id = (SELECT `id` FROM `fa_auth_rule` WHERE `name` = 'settle' LIMIT 1); +INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`) +SELECT 'file', @settle_id, 'settle/detail', '对账详情', 'fa fa-circle-o', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal' +FROM DUAL WHERE @settle_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'settle/detail'); + +SET @withdraw_id = (SELECT `id` FROM `fa_auth_rule` WHERE `name` = 'withdraw' LIMIT 1); +INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`) +SELECT 'file', @withdraw_id, 'withdraw/detail', '对账详情', 'fa fa-circle-o', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal' +FROM DUAL WHERE @withdraw_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'withdraw/detail'); + +SET @invoice_id = (SELECT `id` FROM `fa_auth_rule` WHERE `name` = 'invoice' LIMIT 1); +INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`) +SELECT 'file', @invoice_id, 'invoice/detail', '开票详情', 'fa fa-circle-o', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal' +FROM DUAL WHERE @invoice_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'invoice/detail'); + +SET @coupon_id = (SELECT `id` FROM `fa_auth_rule` WHERE `name` = 'coupon' LIMIT 1); +INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`) +SELECT 'file', @coupon_id, 'coupon/detail', '领用数据详情', 'fa fa-circle-o', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal' +FROM DUAL WHERE @coupon_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'coupon/detail'); + +SET @message_id = (SELECT `id` FROM `fa_auth_rule` WHERE `name` = 'message' LIMIT 1); +INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`) +SELECT 'file', @message_id, 'message/detail', '消息详情', 'fa fa-circle-o', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal' +FROM DUAL WHERE @message_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'message/detail'); + +-- 5. 移除系统配置中残留的 FastAdmin 空演示分组(example)并升级静态资源版本号,强制刷新浏览器 RequireJS 缓存 +UPDATE `fa_config` SET `value` = JSON_REMOVE(`value`, '$.example') WHERE `name` = 'configgroup' AND JSON_VALID(`value`); +UPDATE `fa_config` SET `value` = '1.0.51' WHERE `name` = 'version'; diff --git a/db/schema_phase18_gallery_template.sql b/db/schema_phase18_gallery_template.sql new file mode 100644 index 0000000..6ce9fc0 --- /dev/null +++ b/db/schema_phase18_gallery_template.sql @@ -0,0 +1,95 @@ +-- ============================================================================== +-- Phase 18: 系统图库、用户专属商品图库、常用商品模板、常用备注模板与订单多图升级 +-- ============================================================================== + +-- 1. 升级订单表 goods_images 容量并补充虚拟号保护标识 +ALTER TABLE `fa_order` MODIFY COLUMN `goods_images` mediumtext COMMENT '多商品结构化JSON与图片列表'; + +-- 2. 官方系统图库表 +CREATE TABLE IF NOT EXISTS `fa_system_gallery` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `cat_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '服务大类ID', + `service_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '二级服务商品ID', + `spec_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '三级规格ID', + `title` varchar(100) NOT NULL DEFAULT '' COMMENT '款式/规格名称', + `image` varchar(255) NOT NULL DEFAULT '' COMMENT '参考图片路径或URL', + `weigh` int(11) NOT NULL DEFAULT '0' COMMENT '权重排序', + `status` enum('normal','hidden') NOT NULL DEFAULT 'normal' COMMENT '状态', + `createtime` bigint(16) DEFAULT NULL COMMENT '创建时间', + `updatetime` bigint(16) DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_cat_service` (`cat_id`, `service_id`, `status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='官方系统图库表'; + +-- 3. 用户/商家专属商品图库表 +CREATE TABLE IF NOT EXISTS `fa_user_goods_gallery` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `user_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '所属用户ID', + `cat_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '关联服务大类ID', + `title` varchar(100) NOT NULL DEFAULT '' COMMENT '商品图片名称', + `image` varchar(255) NOT NULL DEFAULT '' COMMENT '商品图片URL', + `createtime` bigint(16) DEFAULT NULL COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `idx_user_cat` (`user_id`, `cat_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户专属商品图库表'; + +-- 4. 用户常用商品模板表 +CREATE TABLE IF NOT EXISTS `fa_user_goods_template` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `user_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '所属用户ID', + `tpl_name` varchar(100) 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', + `item_name` varchar(100) NOT NULL DEFAULT '' COMMENT '商品名称', + `spec_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '规格ID', + `spec_name` varchar(100) NOT NULL DEFAULT '' COMMENT '规格名称', + `unit` varchar(20) NOT NULL DEFAULT '个' COMMENT '单位', + `num` int(11) NOT NULL DEFAULT '1' COMMENT '默认数量', + `service_type` varchar(50) NOT NULL DEFAULT 'install' COMMENT '服务类型编码', + `service_type_name` varchar(50) NOT NULL DEFAULT '安装' COMMENT '服务类型名称', + `is_custom` tinyint(1) unsigned NOT NULL DEFAULT '0' COMMENT '是否自定义商品', + `custom_name` varchar(100) NOT NULL DEFAULT '' COMMENT '自定义商品名称', + `extras_json` text COMMENT '增项服务配置JSON', + `images_json` text COMMENT '商品图片列表JSON', + `remark` varchar(255) NOT NULL DEFAULT '' COMMENT '商品备注', + `createtime` bigint(16) DEFAULT NULL COMMENT '创建时间', + `updatetime` bigint(16) DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_user_cat` (`user_id`, `cat_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户常用商品模板表'; + +-- 5. 常用服务备注模板表 +CREATE TABLE IF NOT EXISTS `fa_remark_template` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `user_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '用户ID(0=平台公共模板)', + `content` varchar(255) NOT NULL DEFAULT '' COMMENT '常用备注内容', + `weigh` int(11) NOT NULL DEFAULT '0' COMMENT '权重排序', + `createtime` bigint(16) DEFAULT NULL COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `idx_user_weigh` (`user_id`, `weigh`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='常用服务备注模板表'; + + +-- ============================================================================ +-- 真实设备与家居实物图校准(覆盖商用厨电、净水器、卫浴、门锁、衣柜、窗帘等全类目) +-- ============================================================================ +UPDATE `fa_system_gallery` SET `image` = 'https://yjyf-1512102519.cos.ap-guangzhou.myqcloud.com/gallery/real_dough_mixer_1.jpg' WHERE `image` LIKE '%/kitchen_mixer.jpg'; +UPDATE `fa_system_gallery` SET `image` = 'https://yjyf-1512102519.cos.ap-guangzhou.myqcloud.com/gallery/real_comm_steamer.jpg' WHERE `image` LIKE '%/kitchen_oven.jpg' OR `image` LIKE '%/commercial_steamer.jpg'; +UPDATE `fa_system_gallery` SET `image` = 'https://yjyf-1512102519.cos.ap-guangzhou.myqcloud.com/gallery/real_comm_stove_1.jpg' WHERE `image` LIKE '%/kitchen_comm_1.jpg'; +UPDATE `fa_system_gallery` SET `image` = 'https://yjyf-1512102519.cos.ap-guangzhou.myqcloud.com/gallery/real_comm_stove_2.jpg' WHERE `image` LIKE '%/kitchen_comm_2.jpg'; +UPDATE `fa_system_gallery` SET `image` = 'https://yjyf-1512102519.cos.ap-guangzhou.myqcloud.com/gallery/real_faucet_sink.jpg' WHERE `image` LIKE '%/kitchen_sink.jpg' OR `image` LIKE '%/bath_faucet.jpg'; +UPDATE `fa_system_gallery` SET `image` = 'https://yjyf-1512102519.cos.ap-guangzhou.myqcloud.com/gallery/real_comm_hood.jpg' WHERE `image` LIKE '%/commercial_hood.jpg'; +UPDATE `fa_system_gallery` SET `image` = 'https://yjyf-1512102519.cos.ap-guangzhou.myqcloud.com/gallery/real_comm_fridge.jpg' WHERE `image` LIKE '%/refrigerator.jpg'; +UPDATE `fa_system_gallery` SET `image` = 'https://yjyf-1512102519.cos.ap-guangzhou.myqcloud.com/gallery/real_water_purifier.jpg' WHERE `image` LIKE '%/water_purifier.jpg'; +UPDATE `fa_system_gallery` SET `image` = 'https://yjyf-1512102519.cos.ap-guangzhou.myqcloud.com/gallery/real_door_lock.jpg' WHERE `image` LIKE '%/mechanical_lock.jpg'; +UPDATE `fa_system_gallery` SET `image` = 'https://yjyf-1512102519.cos.ap-guangzhou.myqcloud.com/goods/security_door.jpg' WHERE `image` LIKE '%/door_interior.jpg' OR `image` LIKE '%/wood_door.jpg'; +UPDATE `fa_system_gallery` SET `image` = 'https://yjyf-1512102519.cos.ap-guangzhou.myqcloud.com/gallery/real_shower_enclosure.jpg' WHERE `image` LIKE '%/shower_room.jpg'; +UPDATE `fa_system_gallery` SET `image` = 'https://yjyf-1512102519.cos.ap-guangzhou.myqcloud.com/gallery/real_wardrobe_closet.jpg' WHERE `image` LIKE '%/wardrobe_sliding.jpg' OR `image` LIKE '%/wardrobe_four.jpg'; +UPDATE `fa_system_gallery` SET `image` = 'https://yjyf-1512102519.cos.ap-guangzhou.myqcloud.com/goods/curtain_motor.jpg' WHERE `image` LIKE '%/curtain_fabric.jpg' OR `image` LIKE '%/curtain_modern.jpg'; +UPDATE `fa_system_gallery` SET `image` = 'https://yjyf-1512102519.cos.ap-guangzhou.myqcloud.com/gallery/real_dough_mixer_1.jpg' WHERE `service_id` = 39 AND `title` LIKE '%12.5kg%'; +UPDATE `fa_system_gallery` SET `image` = 'https://yjyf-1512102519.cos.ap-guangzhou.myqcloud.com/gallery/real_dough_mixer_2.jpg' WHERE `service_id` = 39 AND `title` LIKE '%25kg%'; +UPDATE `fa_system_gallery` SET `image` = 'https://yjyf-1512102519.cos.ap-guangzhou.myqcloud.com/gallery/real_pasta_sheeter.jpg' WHERE `service_id` = 39 AND `title` LIKE '%压面机%'; +UPDATE `fa_system_gallery` SET `image` = 'https://yjyf-1512102519.cos.ap-guangzhou.myqcloud.com/gallery/real_comm_dishwasher.jpg' WHERE `service_id` = 36; +UPDATE `fa_system_gallery` SET `image` = 'https://yjyf-1512102519.cos.ap-guangzhou.myqcloud.com/gallery/real_comm_fridge.jpg' WHERE `service_id` = 38; +UPDATE `fa_system_gallery` SET `image` = 'https://yjyf-1512102519.cos.ap-guangzhou.myqcloud.com/gallery/real_water_boiler.jpg' WHERE `service_id` = 40; diff --git a/server/application/admin/command/OrderAutoverify.php b/server/application/admin/command/OrderAutoverify.php index ff0f56e..c73fca7 100644 --- a/server/application/admin/command/OrderAutoverify.php +++ b/server/application/admin/command/OrderAutoverify.php @@ -35,32 +35,39 @@ class OrderAutoverify extends Command $success = 0; foreach ($orders as $order) { $orderId = (int)$order['id']; - $masterId = (int)$order['master_id']; - $payAmount = (float)$order['pay_amount']; - $rate = 10.00; - $commission = round($payAmount * $rate / 100, 2); - $settleAmount = round($payAmount - $commission, 2); + $now = time(); Db::startTrans(); try { + $lockedOrder = Db::name('order')->where('id', $orderId)->lock(true)->find(); + if (!$lockedOrder || (int)$lockedOrder['status'] !== OrderStatus::PENDING_VERIFY || (int)$lockedOrder['settle_status'] === 1) { + Db::rollback(); + continue; + } + + $masterId = (int)$lockedOrder['master_id']; + $payAmount = (float)$lockedOrder['pay_amount']; + $rate = 10.00; + $commission = round($payAmount * $rate / 100, 2); + $settleAmount = round($payAmount - $commission, 2); + // 1. 更新订单为已完成 Db::name('order')->where('id', $orderId)->update([ 'status' => OrderStatus::COMPLETED, - 'verify_time' => time(), + 'verify_time' => $now, 'settle_status' => 1, 'settle_amount' => $settleAmount, - 'updatetime' => time(), + 'updatetime' => $now, ]); - // 2. 插入订单日志 + // 2. 插入订单日志(严格对齐 fa_order_log 真实列名) Db::name('order_log')->insert([ - 'order_id' => $orderId, - 'from_status'=> OrderStatus::PENDING_VERIFY, - 'to_status' => OrderStatus::COMPLETED, - 'remark' => '完工满48小时客户未确认,系统自动验收结算', - 'operator' => 'system', - 'op_id' => 0, - 'createtime' => time(), + 'order_id' => $orderId, + 'status' => OrderStatus::COMPLETED, + 'remark' => '完工满48小时客户未确认,系统自动验收结算', + 'operator_type' => 'system', + 'operator_id' => 0, + 'createtime' => $now, ]); // 3. 写入结算表 @@ -72,46 +79,48 @@ class OrderAutoverify extends Command 'commission' => $commission, 'settle_amount' => $settleAmount, 'status' => 'settled', - 'settle_time' => time(), - 'createtime' => time(), + 'settle_time' => $now, + 'createtime' => $now, ]); // 4. 师傅钱包入账(行级悲观锁) if ($masterId > 0) { $master = Db::name('master')->where('id', $masterId)->lock(true)->find(); - $newMoney = round((float)$master['money'] + $settleAmount, 2); - Db::name('master')->where('id', $masterId)->update([ - 'money' => $newMoney, - 'order_count' => (int)$master['order_count'] + 1, - 'updatetime' => time(), - ]); - Db::name('wallet_log')->insert([ - 'user_type' => 'master', - 'user_id' => $masterId, - 'type' => 'settle', - 'amount' => $settleAmount, - 'balance' => $newMoney, - 'order_id' => $orderId, - 'remark' => '订单超时自动核销结算入账', - 'createtime' => time(), - ]); + if ($master) { + $newMoney = round((float)$master['money'] + $settleAmount, 2); + Db::name('master')->where('id', $masterId)->update([ + 'money' => $newMoney, + 'order_count' => (int)$master['order_count'] + 1, + 'updatetime' => $now, + ]); + Db::name('wallet_log')->insert([ + 'user_type' => 'master', + 'user_id' => $masterId, + 'type' => 'settle', + 'amount' => $settleAmount, + 'balance' => $newMoney, + 'order_id' => $orderId, + 'remark' => '订单超时自动核销结算入账', + 'createtime' => $now, + ]); - // 通知师傅 - Db::name('message')->insert([ - 'user_type' => 'master', - 'user_id' => (int)$master['user_id'], - 'title' => '订单自动核销结算入账', - 'content' => "订单 {$order['order_no']} 完工满48小时已自动验收,收入 ¥{$settleAmount}", - 'type' => 'order', - 'target_id' => $orderId, - 'is_read' => 0, - 'createtime' => time(), - ]); + // 通知师傅(严格对齐 fa_message 真实列名,receive_id 传 fa_master.id) + Db::name('message')->insert([ + 'receive_type' => 'master', + 'receive_id' => (int)$master['id'], + 'type' => 'order', + 'title' => '订单自动核销结算入账', + 'content' => "订单 {$lockedOrder['order_no']} 完工满48小时已自动验收,收入 ¥{$settleAmount}", + 'order_id' => $orderId, + 'is_read' => 0, + 'createtime' => $now, + ]); + } } Db::commit(); $success++; - $output->writeln(" [OK] 订单 #{$orderId} ({$order['order_no']}) 自动核销完成,结算 ¥{$settleAmount}"); + $output->writeln(" [OK] 订单 #{$orderId} ({$lockedOrder['order_no']}) 自动核销完成,结算 ¥{$settleAmount}"); } catch (\Exception $e) { Db::rollback(); $output->writeln(" [FAIL] 订单 #{$orderId} 自动核销异常: " . $e->getMessage()); diff --git a/server/application/admin/command/OrderRecycle.php b/server/application/admin/command/OrderRecycle.php index 211e7d5..20a5942 100644 --- a/server/application/admin/command/OrderRecycle.php +++ b/server/application/admin/command/OrderRecycle.php @@ -41,69 +41,64 @@ class OrderRecycle extends Command $recycled = 0; foreach ($orders as $order) { $orderId = (int)$order['id']; - $masterId = (int)$order['master_id']; + $now = time(); Db::startTrans(); try { - // 1. 退回待接单状态(公海接单大厅) + $lockedOrder = Db::name('order')->where('id', $orderId)->lock(true)->find(); + if (!$lockedOrder || (int)$lockedOrder['status'] !== OrderStatus::ACCEPTED || !empty($lockedOrder['contact_time'])) { + Db::rollback(); + continue; + } + + $masterId = (int)$lockedOrder['master_id']; + $toStatus = $lockedOrder['price_type'] === 'quote' ? OrderStatus::QUOTING : OrderStatus::PENDING_ACCEPT; + + // 1. 退回待接单/报价中状态(公海接单大厅) Db::name('order')->where('id', $orderId)->update([ - 'status' => OrderStatus::PENDING_ACCEPT, + 'status' => $toStatus, 'master_id' => 0, - 'accept_time' => 0, - 'updatetime' => time(), + 'accept_time' => null, + 'updatetime' => $now, ]); - // 2. 插入订单状态变迁日志 + if ($lockedOrder['price_type'] === 'quote') { + Db::name('order_quote') + ->where('order_id', $orderId) + ->where('status', 'accept') + ->update(['status' => 'wait']); + } + + // 2. 插入订单状态变迁日志(严格对齐 fa_order_log 真实列名) Db::name('order_log')->insert([ - 'order_id' => $orderId, - 'from_status'=> OrderStatus::ACCEPTED, - 'to_status' => OrderStatus::PENDING_ACCEPT, - 'remark' => '师傅接单后超2小时未联系客户,系统自动退回公海大厅', - 'operator' => 'system', - 'op_id' => 0, - 'createtime' => time(), + 'order_id' => $orderId, + 'status' => $toStatus, + 'remark' => '师傅接单后超2小时未联系客户,系统自动退回公海大厅', + 'operator_type' => 'system', + 'operator_id' => 0, + 'createtime' => $now, ]); - // 3. 对原接单师傅扣减信用分(扣 2 分)与推送警告 + // 3. 给原接单师傅发送超时退单警告通知(严格对齐 fa_message 真实列名,receive_id 传 fa_master.id) if ($masterId > 0) { - $master = Db::name('master')->where('id', $masterId)->lock(true)->find(); + $master = Db::name('master')->where('id', $masterId)->find(); if ($master) { - $newScore = max(0, (float)$master['credit_score'] - 2.0); - Db::name('master')->where('id', $masterId)->update([ - 'credit_score' => $newScore, - 'updatetime' => time(), - ]); - - // 记录信誉流水表(如表存在) - if (Db::query("SHOW TABLES LIKE '%master_credit_log%'")) { - Db::name('master_credit_log')->insert([ - 'master_id' => $masterId, - 'type' => 'contact_timeout', - 'score' => -2.0, - 'balance' => $newScore, - 'order_id' => $orderId, - 'remark' => '接单后超2小时未首联,违规扣除信誉分', - 'createtime' => time(), - ]); - } - - // 给师傅发送通知 Db::name('message')->insert([ - 'user_type' => 'master', - 'user_id' => (int)$master['user_id'], - 'title' => '接单超时退单警告', - 'content' => "订单 {$order['order_no']} 因超过2小时未联系客户,已被系统回收退回公海,扣除信誉分 2 分", - 'type' => 'order', - 'target_id' => $orderId, - 'is_read' => 0, - 'createtime' => time(), + 'receive_type' => 'master', + 'receive_id' => (int)$master['id'], + 'type' => 'order', + 'title' => '接单超时退单警告', + 'content' => "订单 {$lockedOrder['order_no']} 因超过2小时未联系客户,已被系统回收退回接单大厅", + 'order_id' => $orderId, + 'is_read' => 0, + 'createtime' => $now, ]); } } Db::commit(); $recycled++; - $output->writeln(" [OK] 订单 #{$orderId} ({$order['order_no']}) 超时退单成功,已重置回公海大厅"); + $output->writeln(" [OK] 订单 #{$orderId} ({$lockedOrder['order_no']}) 超时退单成功,已重置回公海大厅"); } catch (\Exception $e) { Db::rollback(); $output->writeln(" [FAIL] 订单 #{$orderId} 超时退单失败: " . $e->getMessage()); diff --git a/server/application/admin/controller/Appeal.php b/server/application/admin/controller/Appeal.php index e50ed55..10ed565 100644 --- a/server/application/admin/controller/Appeal.php +++ b/server/application/admin/controller/Appeal.php @@ -3,21 +3,27 @@ namespace app\admin\controller; use app\common\controller\Backend; +use think\Db; +use think\exception\PDOException; /** - * 申诉工单 + * 申诉工单管理 * - * @icon fa fa-circle-o + * 申诉单由师傅端发起,后台承担平台客服核实与判责答复职责 + * + * @icon fa fa-gavel */ class Appeal extends Backend { - /** * Appeal模型对象 * @var \app\admin\model\Appeal */ protected $model = null; + protected $relationSearch = true; + protected $searchFields = 'id,content,reply,order.order_no,master.realname,master.mobile'; + public function _initialize() { parent::_initialize(); @@ -26,18 +32,9 @@ class Appeal 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 +53,143 @@ class Appeal extends Backend $result = ['total' => $list->total(), 'rows' => $list->items()]; return json($result); } + + /** + * 申诉全景详情(关联订单、雇主、现场凭证与客户评价) + */ + public function detail($ids = null) + { + $row = $this->model->get($ids); + if (!$row) { + $this->error(__('No Results were found')); + } + $this->assignAppealContext($row); + return $this->view->fetch(); + } + + /** + * 仲裁核查与答复(保护师傅原始申诉凭证只读,仅更新状态与平台答复) + */ + public function edit($ids = null) + { + $row = $this->model->get($ids); + if (!$row) { + $this->error(__('No Results were found')); + } + if (false === $this->request->isPost()) { + $this->assignAppealContext($row); + return $this->view->fetch(); + } + + $params = $this->request->post('row/a', []); + $status = (string)($params['status'] ?? $row['status']); + $reply = trim((string)($params['reply'] ?? '')); + + $statusList = $this->model->getStatusList(); + if (!isset($statusList[$status])) { + $this->error('工单流转状态不合法'); + } + if (in_array($status, ['done', 'reject'], true) && $reply === '') { + $this->error('标记完结或驳回申诉时,必须填写平台核查答复说明'); + } + + $oldStatus = (string)$row['status']; + $oldReply = (string)$row['reply']; + + Db::startTrans(); + try { + $row->allowField(['status', 'reply'])->save([ + 'status' => $status, + 'reply' => mb_substr($reply, 0, 500), + ]); + + // 状态流转或答复更新时,自动推送站内通知给申诉师傅 + if (($status !== $oldStatus || $reply !== $oldReply) && (int)$row['master_id'] > 0) { + $statusText = $statusList[$status] ?? $status; + $orderNo = ''; + if ((int)$row['order_id'] > 0) { + $orderNo = (string)Db::name('order')->where('id', (int)$row['order_id'])->value('order_no'); + } + $msgTitle = '申诉工单处理通知(' . $statusText . ')'; + $msgContent = ($orderNo !== '' ? '关联订单 ' . $orderNo . ' 的' : '您的') . '申诉状态已更新为「' . $statusText . '」'; + if ($reply !== '') { + $msgContent .= '。平台答复:' . $reply; + } + Db::name('message')->insert([ + 'receive_type' => 'master', + 'receive_id' => (int)$row['master_id'], + 'type' => 'audit', + 'title' => mb_substr($msgTitle, 0, 100), + 'content' => mb_substr($msgContent, 0, 500), + 'order_id' => (int)$row['order_id'], + 'is_read' => 0, + 'createtime' => time(), + ]); + } + + 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('申诉工单与判责记录禁止删除'); + } + + /** + * 组装申诉关联上下文数据 + */ + protected function assignAppealContext($row) + { + $master = (int)$row['master_id'] > 0 + ? Db::name('master')->where('id', (int)$row['master_id'])->find() + : null; + $order = (int)$row['order_id'] > 0 + ? Db::name('order')->where('id', (int)$row['order_id'])->find() + : null; + $customer = ($order && (int)$order['user_id'] > 0) + ? Db::name('user')->where('id', (int)$order['user_id'])->field('id,nickname,username,mobile')->find() + : null; + $review = ($order && (int)$order['id'] > 0) + ? Db::name('review')->where('order_id', (int)$order['id'])->find() + : null; + $orderMedias = ($order && (int)$order['id'] > 0) + ? Db::name('order_media')->where('order_id', (int)$order['id'])->order('id asc')->select() + : []; + + $appealImages = array_values(array_filter(array_map('trim', explode(',', (string)$row['images'])))); + $reviewImages = $review ? array_values(array_filter(array_map('trim', explode(',', (string)$review['images'])))) : []; + + $orderStatusMap = [ + 0 => '待支付', 1 => '待接单', 2 => '待上门', 3 => '已到达', + 4 => '服务中', 5 => '待验收', 6 => '已完成', 9 => '已取消', 10 => '售后/异常' + ]; + + $this->view->assign([ + 'row' => $row, + 'master' => $master ?: [], + 'order' => $order ?: [], + 'customer' => $customer ?: [], + 'review' => $review ?: [], + 'orderMedias' => $orderMedias, + 'appealImages' => $appealImages, + 'reviewImages' => $reviewImages, + 'orderStatusMap' => $orderStatusMap, + ]); + } } diff --git a/server/application/admin/controller/Banner.php b/server/application/admin/controller/Banner.php index 3686e88..202c8c1 100644 --- a/server/application/admin/controller/Banner.php +++ b/server/application/admin/controller/Banner.php @@ -17,6 +17,7 @@ class Banner extends Backend * @var \app\admin\model\Banner */ protected $model = null; + protected $searchFields = 'id,badge,title,subtitle,link'; public function _initialize() { diff --git a/server/application/admin/controller/Coupon.php b/server/application/admin/controller/Coupon.php index 6ccd21f..c69837a 100644 --- a/server/application/admin/controller/Coupon.php +++ b/server/application/admin/controller/Coupon.php @@ -3,20 +3,21 @@ namespace app\admin\controller; use app\common\controller\Backend; +use think\Db; /** - * 优惠券模板 + * 优惠券模板管理 * - * @icon fa fa-circle-o + * @icon fa fa-ticket */ class Coupon extends Backend { - /** * Coupon模型对象 * @var \app\admin\model\Coupon */ protected $model = null; + protected $searchFields = 'id,name'; public function _initialize() { @@ -26,13 +27,181 @@ class Coupon 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(); + $ids = []; + foreach ($rows as $r) { + $ids[] = (int)$r['id']; + } + + $claimMap = []; + $usedMap = []; + if ($ids) { + $stats = Db::name('user_coupon') + ->where('coupon_id', 'in', $ids) + ->field('coupon_id, COUNT(*) AS claim_cnt, SUM(CASE WHEN status = \'used\' THEN 1 ELSE 0 END) AS used_cnt') + ->group('coupon_id') + ->select(); + foreach ($stats as $st) { + $cid = (int)$st['coupon_id']; + $claimMap[$cid] = (int)$st['claim_cnt']; + $usedMap[$cid] = (int)$st['used_cnt']; + } + } + + foreach ($rows as &$item) { + $cid = (int)$item['id']; + $item['real_claimed'] = max((int)$item['received'], $claimMap[$cid] ?? 0); + $item['used_count'] = $usedMap[$cid] ?? 0; + } + unset($item); + + return json(['total' => $list->total(), 'rows' => $rows]); + } /** - * 默认生成的控制器所继承的父类中有index/add/edit/del/multi五个基础方法、destroy/restore/recyclebin三个回收站方法 - * 因此在当前控制器中可不用编写增删改查的代码,除非需要自己控制这部分逻辑 - * 需要将application/admin/library/traits/Backend.php中对应的方法复制到当前控制器,然后进行修改 + * 优惠券领用与核销全景详情(关联 fa_user_coupon + fa_user + fa_order) */ + public function detail($ids = null) + { + $row = $this->model->get($ids); + if (!$row) { + $this->error(__('No Results were found')); + } + $cid = (int)$row['id']; + $totalClaimed = (int)Db::name('user_coupon')->where('coupon_id', $cid)->count(); + $totalUsed = (int)Db::name('user_coupon')->where('coupon_id', $cid)->where('status', 'used')->count(); + $records = Db::name('user_coupon')->alias('uc') + ->join('__USER__ u', 'u.id = uc.user_id', 'LEFT') + ->join('__ORDER__ o', 'o.id = uc.order_id', 'LEFT') + ->where('uc.coupon_id', $cid) + ->field('uc.*, u.nickname, u.username, u.mobile, o.order_no, o.service_name, o.pay_amount, o.discount_amount') + ->order('uc.id desc') + ->limit(30) + ->select(); + $drivenGmv = (float)Db::name('user_coupon')->alias('uc') + ->join('__ORDER__ o', 'o.id = uc.order_id', 'INNER') + ->where('uc.coupon_id', $cid) + ->where('uc.status', 'used') + ->sum('o.pay_amount'); + + $this->view->assign([ + 'row' => $row, + 'totalClaimed' => max((int)$row['received'], $totalClaimed), + 'totalUsed' => $totalUsed, + 'drivenGmv' => round($drivenGmv, 2), + 'records' => $records, + ]); + return $this->view->fetch(); + } + + /** + * 新增卡券模板(强制初始化已领取数为 0) + */ + public function add() + { + if (false === $this->request->isPost()) { + return $this->view->fetch(); + } + $params = $this->request->post('row/a', []); + if (!$params) { + $this->error(__('Parameter %s can not be empty', '')); + } + $params = $this->preExcludeFields($params); + $params['received'] = 0; + if (($params['type'] ?? 'cash') === 'discount') { + $disc = (float)($params['discount'] ?? 0); + if ($disc <= 0 || $disc >= 1) { + $this->error('折扣券的折扣系数须大于 0 且小于 1(如 0.85 表示 8.5 折)'); + } + $params['amount'] = 0.00; + } else { + if ((float)($params['amount'] ?? 0) <= 0) { + $this->error('满减代金券的减免金额须大于 0'); + } + $params['discount'] = 1.00; + } + $result = $this->model->allowField(true)->save($params); + if ($result !== false) { + $this->success(); + } + $this->error($this->model->getError() ?: __('No rows were inserted')); + } + + /** + * 编辑卡券模板(锁定已领取数量防止人工篡改库存) + */ + 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); + return $this->view->fetch(); + } + $params = $this->request->post('row/a', []); + if (!$params) { + $this->error(__('Parameter %s can not be empty', '')); + } + $params = $this->preExcludeFields($params); + unset($params['received']); + if (($params['type'] ?? $row['type']) === 'discount') { + $disc = (float)($params['discount'] ?? $row['discount']); + if ($disc <= 0 || $disc >= 1) { + $this->error('折扣券的折扣系数须大于 0 且小于 1(如 0.85 表示 8.5 折)'); + } + $params['amount'] = 0.00; + } else { + if ((float)($params['amount'] ?? $row['amount']) <= 0) { + $this->error('满减代金券的减免金额须大于 0'); + } + $params['discount'] = 1.00; + } + $result = $row->allowField(true)->save($params); + if ($result !== false) { + $this->success(); + } + $this->error($row->getError() ?: __('No rows were updated')); + } + + /** + * 删除保护:已有用户领取的卡券禁止物理删除 + */ + public function del($ids = null) + { + if (false === $this->request->isPost()) { + $this->error(__('Invalid parameters')); + } + $ids = $ids ?: $this->request->post('ids'); + $idArr = array_values(array_filter(array_map('intval', explode(',', (string)$ids)))); + if (!$idArr) { + $this->error(__('Parameter %s can not be empty', 'ids')); + } + $claimedCnt = (int)Db::name('user_coupon')->where('coupon_id', 'in', $idArr)->count(); + if ($claimedCnt > 0) { + $this->error('所选优惠券已有用户领取记录(共 ' . $claimedCnt . ' 张),禁止物理删除,请改为「隐藏」停止发放'); + } + return parent::del($ids); + } } diff --git a/server/application/admin/controller/Home.php b/server/application/admin/controller/Home.php index 9f3b9ba..d50a8af 100644 --- a/server/application/admin/controller/Home.php +++ b/server/application/admin/controller/Home.php @@ -34,20 +34,34 @@ class Home extends Backend $todayEnd = strtotime(date('Y-m-d 23:59:59')); $monthStart = strtotime(date('Y-m-01 00:00:00')); - // 2. 核心经营 KPI (真实数据聚合) + // 2. 核心经营 KPI (真实数据聚合:仅统计已支付且未取消的有效订单流水) $totalOrder = Order::count(); - $totalGmv = (float)(Order::sum('pay_amount') ?: 0); - $avgTicket = $totalOrder > 0 ? round($totalGmv / $totalOrder, 2) : 0; + $paidOrderCount = Order::where('pay_status', 1)->where('status', '<>', 9)->count(); + $totalGmv = (float)(Order::where('pay_status', 1)->where('status', '<>', 9)->sum('pay_amount') ?: 0); + $avgTicket = $paidOrderCount > 0 ? round($totalGmv / $paidOrderCount, 2) : 0; $todayOrderCount = Order::whereBetween('createtime', [$todayStart, $todayEnd])->count(); - $todayGmv = (float)(Order::whereBetween('pay_time', [$todayStart, $todayEnd])->sum('pay_amount') ?: 0); - $monthGmv = (float)(Order::whereBetween('pay_time', [$monthStart, $todayEnd])->sum('pay_amount') ?: 0); + $todayGmv = (float)(Db::name('order') + ->where('pay_status', 1) + ->where('status', '<>', 9) + ->whereRaw('IF(pay_time > 0, pay_time, createtime) BETWEEN ' . (int)$todayStart . ' AND ' . (int)$todayEnd) + ->sum('pay_amount') ?: 0); + $monthGmv = (float)(Db::name('order') + ->where('pay_status', 1) + ->where('status', '<>', 9) + ->whereRaw('IF(pay_time > 0, pay_time, createtime) BETWEEN ' . (int)$monthStart . ' AND ' . (int)$todayEnd) + ->sum('pay_amount') ?: 0); // 运力与商户统计 $totalMaster = Master::where('verify_status', 'pass')->count(); - $onlineMaster = Master::where('work_status', 'on')->count(); + $onlineMaster = Master::where('verify_status', 'pass')->where('work_status', 'on')->count(); $totalMerchant = Merchant::where('verify_status', 'pass')->count(); - $totalUser = User::count(); + $totalUser = Db::name('user')->alias('u') + ->join('master ma', 'ma.user_id = u.id', 'LEFT') + ->join('merchant me', 'me.user_id = u.id', 'LEFT') + ->whereNull('ma.id') + ->whereNull('me.id') + ->count(); // 3. 订单状态分布与履约漏斗 $statusCounts = Order::field('status, count(*) as cnt') @@ -60,17 +74,19 @@ class Home extends Backend $unpaidCount = $statusCountMap[0] ?? 0; $waitDispatchCount = $statusCountMap[1] ?? 0; - $inServiceCount = ($statusCountMap[3] ?? 0) + ($statusCountMap[6] ?? 0); + $inServiceCount = ($statusCountMap[3] ?? 0) + ($statusCountMap[4] ?? 0) + ($statusCountMap[5] ?? 0) + ($statusCountMap[6] ?? 0) + ($statusCountMap[7] ?? 0); $doneCount = $statusCountMap[8] ?? 0; $afterSaleCount = $statusCountMap[10] ?? 0; - // 4. 实时待办预警与风控中心 + // 4. 实时待办预警与风控中心(6大核心运营待办) $pendingMaster = Master::where('verify_status', 'pending')->count(); + $pendingMerchant = Merchant::where('verify_status', 'pending')->count(); $waitingAppeal = Appeal::where('status', 'wait')->count(); $waitingWithdraw = Withdraw::where('status', 'wait')->count(); + $waitingInvoice = Db::name('invoice')->where('status', 'wait')->count(); $pendingDispatch = $waitDispatchCount; - // 5. 近 7 天订单趋势与 GMV 流水 (双轴图表数据) + // 5. 近 7 天订单趋势与已付 GMV 流水 (双轴图表数据) $dateColumns = []; $orderCounts = []; $orderAmounts = []; @@ -81,7 +97,11 @@ class Home extends Backend $dayEnd = strtotime($date . ' 23:59:59'); $cnt = Order::whereBetween('createtime', [$dayStart, $dayEnd])->count(); - $amt = Order::whereBetween('createtime', [$dayStart, $dayEnd])->sum('pay_amount') ?: 0; + $amt = Db::name('order') + ->where('pay_status', 1) + ->where('status', '<>', 9) + ->whereRaw('IF(pay_time > 0, pay_time, createtime) BETWEEN ' . (int)$dayStart . ' AND ' . (int)$dayEnd) + ->sum('pay_amount') ?: 0; $orderCounts[] = (int)$cnt; $orderAmounts[] = round((float)$amt, 2); @@ -147,8 +167,10 @@ class Home extends Backend 'afterSaleCount' => $afterSaleCount, 'pendingMaster' => $pendingMaster, + 'pendingMerchant' => $pendingMerchant, 'waitingAppeal' => $waitingAppeal, 'waitingWithdraw' => $waitingWithdraw, + 'waitingInvoice' => $waitingInvoice, 'pendingDispatch' => $pendingDispatch, 'masterAvgScore' => $masterAvg['avg_score'] ?: '5.0', diff --git a/server/application/admin/controller/Invoice.php b/server/application/admin/controller/Invoice.php index 3604203..35ab260 100644 --- a/server/application/admin/controller/Invoice.php +++ b/server/application/admin/controller/Invoice.php @@ -3,30 +3,32 @@ namespace app\admin\controller; use app\common\controller\Backend; +use app\common\library\InvoiceService; use think\Db; use think\exception\PDOException; /** - * 开票申请 + * 开票申请管理 * - * 开票申请仅由用户/商户端发起,后台只负责财务开具处理: - * 待开具(wait) → 已开票(issued,必填发票号)/驳回(reject,必填原因) - * 驳回后额度自动释放,并通知申请人 + * 开票申请仅由用户/商户端发起,后台负责财务审核与开具交付: + * 待开具(wait) → 已开票(issued,必填发票号,支持上传电子发票PDF/图片,锁定关联订单为已开票) + * → 驳回(reject,必填原因,自动释放开票额度并解锁关联订单) * * @icon fa fa-file-text-o */ class Invoice extends Backend { - /** * Invoice模型对象 * @var \app\admin\model\Invoice */ protected $model = null; + protected $relationSearch = true; + protected $searchFields = 'id,invoice_sn,title,tax_no,invoice_no,email,merchant.name,user.nickname'; /** * 开票处理状态机:当前状态 => 允许流转到的目标状态 - * issued/reject 为终态 + * issued / reject / cancel 为终态 * @var array */ protected $statusFlow = [ @@ -38,6 +40,7 @@ class Invoice extends Backend parent::_initialize(); $this->model = new \app\admin\model\Invoice; $this->view->assign("typeList", $this->model->getTypeList()); + $this->view->assign("titleTypeList", $this->model->getTitleTypeList()); $this->view->assign("statusList", $this->model->getStatusList()); } @@ -64,29 +67,46 @@ class Invoice extends Backend } /** - * 财务开票处理:申请信息只读,仅允许填写开票结果 - * - 开票完成:必须填写发票号,状态置为已开票 - * - 驳回:必须填写原因,额度自动释放 + * 详情回看(复用 edit 只读视图) + */ + public function detail($ids = null) + { + $row = $this->model->with(['user', 'merchant'])->where('invoice.id', (int)$ids)->find(); + if (!$row) { + $this->error(__('No Results were found')); + } + $linkedOrders = InvoiceService::fetchLinkedOrders((string)$row['order_ids'], (int)$row['id']); + $this->view->assign('row', $row); + $this->view->assign('linkedOrders', $linkedOrders); + $this->view->assign('flowOptions', []); + return $this->view->fetch('edit'); + } + + /** + * 财务开票处理:申请信息只读,仅允许填写开票结果与上传电子发票文件 */ public function edit($ids = null) { - $row = $this->model->get($ids); + $row = $this->model->with(['user', 'merchant'])->where('invoice.id', (int)$ids)->find(); if (!$row) { $this->error(__('No Results were found')); } if (false === $this->request->isPost()) { + $linkedOrders = InvoiceService::fetchLinkedOrders((string)$row['order_ids'], (int)$row['id']); $this->view->assign('row', $row); + $this->view->assign('linkedOrders', $linkedOrders); $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')); + $status = (string)$this->request->post('row.status'); + $invoiceCode = trim((string)$this->request->post('row.invoice_code')); + $invoiceNo = trim((string)$this->request->post('row.invoice_no')); + $invoiceFile = trim((string)$this->request->post('row.invoice_file')); + $auditMsg = trim((string)$this->request->post('row.audit_msg')); - // 状态机校验:终态禁止操作,流转目标必须合法 if (!isset($this->statusFlow[$row['status']])) { - $this->error('该开票申请已处理完毕,不允许再次变更'); + $this->error('该开票申请已处理或已撤回,不允许再次变更'); } if (!in_array($status, $this->statusFlow[$row['status']], true)) { $this->error('状态流转不合法,请刷新后重试'); @@ -98,30 +118,49 @@ class Invoice extends Backend $this->error('驳回时必须填写驳回原因'); } + $now = time(); Db::startTrans(); try { + $updateData = [ + 'status' => $status, + 'invoice_code' => $invoiceCode !== '' ? mb_substr($invoiceCode, 0, 50) : $row['invoice_code'], + 'invoice_no' => $invoiceNo !== '' ? mb_substr($invoiceNo, 0, 50) : $row['invoice_no'], + 'invoice_file' => $invoiceFile !== '' ? mb_substr($invoiceFile, 0, 500) : $row['invoice_file'], + 'audit_msg' => mb_substr($auditMsg, 0, 255), + 'updatetime' => $now, + ]; + if ($status === 'issued') { + $updateData['issue_time'] = $now; + } + // 乐观锁更新状态,防止并发重复处理 $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(), - ]); + ->update($updateData); if (!$affected) { throw new \Exception('开票申请状态已被其他人变更,请刷新后重试'); } - // 通知申请人(商户开票通知绑定账号用户) - $this->sendUserMessage( - (int)$row['user_id'], - $status === 'issued' ? '发票已开具' : '开票申请已驳回', - $status === 'issued' - ? '您申请的开票 ¥' . $row['amount'] . ' 已开具完成,发票号码:' . $invoiceNo . ',电子发票已发送至 ' . $row['email'] - : '您的开票申请 ¥' . $row['amount'] . ' 未通过审核:' . ($auditMsg !== '' ? $auditMsg : '无') - ); + if ($status === 'issued') { + InvoiceService::markInvoiceOrdersIssued((int)$row['id'], (string)$row['order_ids'], $now); + $msgBody = '您申请的发票 ¥' . number_format((float)$row['amount'], 2, '.', '') . ' 已开具完成,发票号码:' . $invoiceNo; + if ($invoiceFile !== '') { + $msgBody .= ',您可在「开具发票 - 开票记录」中在线预览或下载电子发票'; + } + if (!empty($row['email'])) { + $msgBody .= '(接收邮箱:' . $row['email'] . ')'; + } + $this->sendUserMessage((int)$row['user_id'], '发票已开具', $msgBody); + } else { + InvoiceService::releaseInvoiceOrders((int)$row['id'], (string)$row['order_ids'], $now); + $this->sendUserMessage( + (int)$row['user_id'], + '开票申请已驳回', + '您的开票申请 ¥' . number_format((float)$row['amount'], 2, '.', '') . ' 未通过审核:' . ($auditMsg !== '' ? $auditMsg : '请核对开票信息后重新提交') + ); + } + Db::commit(); } catch (PDOException|\Exception $e) { Db::rollback(); @@ -156,9 +195,6 @@ class Invoice extends Backend /** * 向申请人发送站内消息 - * @param int $userId 用户ID - * @param string $title 消息标题 - * @param string $content 消息内容 */ protected function sendUserMessage($userId, $title, $content) { @@ -167,7 +203,7 @@ class Invoice extends Backend } Db::name('message')->insert([ 'receive_type' => 'user', - 'receive_id' => $userId, + 'receive_id' => (int)$userId, 'type' => 'system', 'title' => mb_substr((string)$title, 0, 100), 'content' => mb_substr((string)$content, 0, 500), diff --git a/server/application/admin/controller/InvoiceTitle.php b/server/application/admin/controller/InvoiceTitle.php new file mode 100644 index 0000000..5127b53 --- /dev/null +++ b/server/application/admin/controller/InvoiceTitle.php @@ -0,0 +1,167 @@ +model = new \app\admin\model\InvoiceTitle; + $this->view->assign('titleTypeList', $this->model->getTitleTypeList()); + $this->view->assign('invoiceTypeList', $this->model->getDefaultInvoiceTypeList()); + } + + /** + * 抬头列表(关联所属用户/签约商户及累计开票统计) + */ + 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 + ->with(['user', 'merchant']) + ->where($where) + ->order($sort, $order) + ->paginate($limit); + + $rows = $list->items(); + $ids = []; + foreach ($rows as $r) { + $ids[] = (int)$r['id']; + } + $invStats = []; + if ($ids) { + $agg = Db::name('invoice') + ->where('title_id', 'in', $ids) + ->where('status', 'issued') + ->field('title_id, COUNT(*) AS cnt, SUM(amount) AS sum_amt') + ->group('title_id') + ->select(); + foreach ($agg as $g) { + $invStats[(int)$g['title_id']] = [ + 'cnt' => (int)$g['cnt'], + 'amt' => round((float)$g['sum_amt'], 2), + ]; + } + } + foreach ($rows as &$item) { + $tid = (int)$item['id']; + $item['issued_count'] = $invStats[$tid]['cnt'] ?? 0; + $item['issued_amount'] = $invStats[$tid]['amt'] ?? 0.00; + } + unset($item); + + return json(['total' => $list->total(), 'rows' => $rows]); + } + + /** + * 发票抬头档案详情(关联历史开票记录) + */ + public function detail($ids = null) + { + $row = $this->model->with(['user', 'merchant'])->where('invoice_title.id', (int)$ids)->find(); + if (!$row) { + $this->error(__('No Results were found')); + } + $invoices = Db::name('invoice') + ->where('title_id', (int)$row['id']) + ->order('id desc') + ->limit(20) + ->select(); + + $this->view->assign([ + 'row' => $row, + 'invoices' => $invoices, + ]); + return $this->view->fetch(); + } + + /** + * 财务协助订正抬头信息(如税号、开户行、邮箱笔误) + */ + 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); + return $this->view->fetch(); + } + $params = $this->request->post('row/a', []); + $titleType = in_array($params['title_type'] ?? '', ['enterprise', 'personal'], true) ? $params['title_type'] : $row['title_type']; + $title = trim((string)($params['title'] ?? '')); + $taxNo = strtoupper(trim((string)($params['tax_no'] ?? ''))); + if ($title === '') { + $this->error('发票抬头名称不能为空'); + } + if ($titleType === 'enterprise' && $taxNo === '') { + $this->error('企业单位抬头必须填写纳税人识别号'); + } + + $row->save([ + 'title_type' => $titleType, + 'default_invoice_type' => in_array($params['default_invoice_type'] ?? '', ['normal', 'special'], true) ? $params['default_invoice_type'] : $row['default_invoice_type'], + 'title' => mb_substr($title, 0, 150), + 'tax_no' => mb_substr($taxNo, 0, 50), + 'bank_name' => mb_substr(trim((string)($params['bank_name'] ?? '')), 0, 100), + 'bank_account' => mb_substr(trim((string)($params['bank_account'] ?? '')), 0, 50), + 'reg_address' => mb_substr(trim((string)($params['reg_address'] ?? '')), 0, 255), + 'reg_mobile' => mb_substr(trim((string)($params['reg_mobile'] ?? '')), 0, 30), + 'email' => mb_substr(trim((string)($params['email'] ?? '')), 0, 100), + 'receive_mobile' => mb_substr(trim((string)($params['receive_mobile'] ?? '')), 0, 20), + ]); + $this->success(); + } + + /** + * 抬头由用户或企业入驻维护,后台不开放手工盲增 + */ + public function add() + { + $this->error('发票抬头由业主或签约企业在小程序端维护,后台无需手工新增'); + } + + /** + * 已关联开票申请的抬头禁止删除 + */ + public function del($ids = null) + { + if (false === $this->request->isPost()) { + $this->error(__('Invalid parameters')); + } + $ids = $ids ?: $this->request->post('ids'); + $idArr = array_values(array_filter(array_map('intval', explode(',', (string)$ids)))); + if (!$idArr) { + $this->error(__('Parameter %s can not be empty', 'ids')); + } + $used = (int)Db::name('invoice')->where('title_id', 'in', $idArr)->count(); + if ($used > 0) { + $this->error('所选发票抬头已关联 ' . $used . ' 笔开票记录,禁止删除'); + } + return parent::del($ids); + } +} diff --git a/server/application/admin/controller/Master.php b/server/application/admin/controller/Master.php index 5c5e058..19e3240 100644 --- a/server/application/admin/controller/Master.php +++ b/server/application/admin/controller/Master.php @@ -3,20 +3,23 @@ namespace app\admin\controller; use app\common\controller\Backend; +use think\Db; +use Exception; /** - * 师傅档案 + * 师傅档案与资质管理 * - * @icon fa fa-circle-o + * @icon fa fa-wrench */ class Master extends Backend { - /** * Master模型对象 * @var \app\admin\model\Master */ protected $model = null; + protected $relationSearch = true; + protected $searchFields = 'id,realname,mobile,city'; public function _initialize() { @@ -26,8 +29,6 @@ class Master extends Backend $this->view->assign("verifyStatusList", $this->model->getVerifyStatusList()); } - protected $relationSearch = true; - public function index() { $this->request->filter(['strip_tags', 'trim']); @@ -44,12 +45,11 @@ class Master extends Backend ->order($sort, $order) ->paginate($limit); - // 合并查出当前页每个师傅拥有的技能 $items = $list->items(); $masterIds = array_column($items, 'id'); $skillsGroup = []; if (!empty($masterIds)) { - $allSkills = \think\Db::name('master_skill') + $allSkills = Db::name('master_skill') ->where('master_id', 'in', $masterIds) ->where('status', 'normal') ->select(); @@ -63,13 +63,169 @@ class Master extends Backend foreach ($items as &$it) { $it['skills_list'] = $skillsGroup[$it['id']] ?? []; } + unset($it); - $result = ['total' => $list->total(), 'rows' => $items]; - return json($result); + return json(['total' => $list->total(), 'rows' => $items]); } /** - * 师傅服务技能管理(内置于师傅列表) + * 新增认证师傅档案(校验用户唯一绑定,强制初始化资金与履约统计为 0) + */ + public function add() + { + if (false === $this->request->isPost()) { + return $this->view->fetch(); + } + $params = $this->request->post('row/a', []); + if (!$params) { + $this->error(__('Parameter %s can not be empty', '')); + } + $params = $this->preExcludeFields($params); + $uid = (int)($params['user_id'] ?? 0); + if ($uid <= 0) { + $this->error('请选择关联的平台注册用户账号'); + } + if (Db::name('master')->where('user_id', $uid)->count() > 0) { + $this->error('该用户已绑定过师傅档案,请勿重复创建'); + } + $params['money'] = 0.00; + $params['frozen_money'] = 0.00; + $params['order_count'] = 0; + $params['level'] = max(1, (int)($params['level'] ?? 1)); + $params['score'] = 5.00; + $params['accept_rate'] = 100.00; + $params['ontime_rate'] = 100.00; + + $result = $this->model->allowField(true)->save($params); + if ($result !== false) { + $this->success(); + } + $this->error($this->model->getError() ?: __('No rows were inserted')); + } + + /** + * 删除保护:已产生承接工单、钱包余额或结算流水的师傅禁止物理删除 + */ + public function del($ids = null) + { + if (false === $this->request->isPost()) { + $this->error(__('Invalid parameters')); + } + $ids = $ids ?: $this->request->post('ids'); + $idArr = array_values(array_filter(array_map('intval', explode(',', (string)$ids)))); + if (!$idArr) { + $this->error(__('Parameter %s can not be empty', 'ids')); + } + $hasOrder = Db::name('order')->where('master_id', 'in', $idArr)->count(); + $hasFund = Db::name('master')->where('id', 'in', $idArr)->where('money > 0 OR frozen_money > 0')->count(); + $hasSettle = Db::name('settle')->where('master_id', 'in', $idArr)->count(); + if ($hasOrder > 0 || $hasFund > 0 || $hasSettle > 0) { + $this->error('所选师傅已关联历史工单、结算记录或存在钱包资金,禁止物理删除;如需停用请关闭听单状态'); + } + Db::startTrans(); + try { + Db::name('master_skill')->where('master_id', 'in', $idArr)->delete(); + $deleted = Db::name('master')->where('id', 'in', $idArr)->delete(); + Db::commit(); + } catch (Exception $e) { + Db::rollback(); + $this->error($e->getMessage()); + } + if ($deleted) { + $this->success(); + } + $this->error(__('No rows were deleted')); + } + + /** + * 编辑师傅资料与入驻资质审核(状态变更自动触发通知) + */ + public function edit($ids = null) + { + $row = $this->model->get($ids); + if (!$row) { + $this->error(__('No Results were found')); + } + $oldVerify = (string)$row['verify_status']; + + if ($this->request->isPost()) { + $params = $this->request->post("row/a"); + if ($params) { + $params = $this->preExcludeFields($params); + // 禁止在师傅档案编辑中直接篡改钱包可用余额、冻结资金与累计完单数 + unset($params['money'], $params['frozen_money'], $params['order_count']); + + // 若提交的身份证号包含脱敏星号 *,保留原数据库记录不覆盖;若为新填写明文则做合法性校验 + if (isset($params['idcard_no'])) { + $idcardInput = trim((string)$params['idcard_no']); + if (strpos($idcardInput, '*') !== false) { + unset($params['idcard_no']); + } elseif ($idcardInput !== '' && !preg_match('/^(^\d{15}$)|(^\d{17}([0-9]|X|x)$)$/', $idcardInput)) { + $this->error('请输入正确的15位或18位身份证号码'); + } + } + + // 若 city-picker 因省市格式差异未选新值导致提交为空而原库有城市,保留原城市不清空 + if (isset($params['city']) && trim((string)$params['city']) === '' && !empty($row['city'])) { + unset($params['city']); + } + + if (isset($params['verify_status']) && $params['verify_status'] === 'reject' && empty(trim((string)($params['audit_msg'] ?? '')))) { + $this->error('驳回实名认证时请填写审核意见说明'); + } + $result = false; + Db::startTrans(); + try { + $result = $row->allowField(true)->save($params); + $newVerify = isset($params['verify_status']) ? (string)$params['verify_status'] : $oldVerify; + if ($newVerify !== $oldVerify && in_array($newVerify, ['pass', 'reject'], true)) { + $now = time(); + $title = $newVerify === 'pass' ? '师傅入驻审核通过' : '师傅入驻审核未通过'; + $content = $newVerify === 'pass' + ? '您的师傅实名认证已审核通过,现在可开启听单承接同城服务工单。' + : ('您的师傅认证申请未通过,原因:' . (trim((string)($params['audit_msg'] ?? '')) ?: '请核对资料后重新提交')); + Db::name('message')->insert([ + 'receive_type' => 'master', + 'receive_id' => (int)$row['id'], + 'type' => 'audit', + 'title' => $title, + 'content' => $content, + 'order_id' => 0, + 'is_read' => 0, + 'createtime' => $now, + ]); + if ((int)$row['user_id'] > 0) { + Db::name('message')->insert([ + 'receive_type' => 'user', + 'receive_id' => (int)$row['user_id'], + 'type' => 'audit', + 'title' => $title, + 'content' => $content, + 'order_id' => 0, + 'is_read' => 0, + 'createtime' => $now, + ]); + } + } + Db::commit(); + } catch (Exception $e) { + Db::rollback(); + $this->error($e->getMessage()); + } + if ($result !== false) { + $this->success(); + } + $this->error(__('No rows were updated')); + } + $this->error(__('Parameter %s can not be empty', '')); + } + + $this->view->assign("row", $row); + return $this->view->fetch(); + } + + /** + * 师傅服务技能管理(保留既有证书与元数据) */ public function skills($ids = null) { @@ -82,83 +238,96 @@ class Master extends Backend $selectedSkills = (array)$this->request->post('skills/a', []); $customText = trim((string)$this->request->post('custom_text', '')); - // 1. 处理自定义输入的技能 $customNames = []; if (!empty($customText)) { $parts = preg_split('/[,,\s]+/u', $customText); foreach ($parts as $p) { $p = trim($p); - if (!empty($p) && !in_array($p, $customNames) && !in_array($p, $selectedSkills)) { + if (!empty($p) && !in_array($p, $customNames, true) && !in_array($p, $selectedSkills, true)) { $customNames[] = $p; } } } - // 2. 清理当前师傅原有的技能 - \think\Db::name('master_skill')->where('master_id', $row['id'])->delete(); + // 缓存师傅原有技能的证书图片与分类ID,防止保存时丢失证书 + $existingRows = Db::name('master_skill')->where('master_id', $row['id'])->select(); + $existingMap = []; + foreach ($existingRows as $er) { + $existingMap[(string)$er['skill_name']] = $er; + } - // 3. 写入选中的技能 - $dictRows = \think\Db::name('skill_dict')->where('name', 'in', $selectedSkills)->select(); + $dictRows = !empty($selectedSkills) + ? Db::name('skill_dict')->where('name', 'in', $selectedSkills)->select() + : []; $dictMap = []; foreach ($dictRows as $dr) { $dictMap[$dr['name']] = $dr; } $now = time(); - foreach ($selectedSkills as $sName) { - $dict = $dictMap[$sName] ?? null; - \think\Db::name('master_skill')->insert([ - 'master_id' => $row['id'], - 'cat_id' => 0, - 'skill_id' => $dict ? $dict['id'] : 0, - 'skill_name' => $sName, - 'category' => $dict ? $dict['category'] : '服务技能', - 'is_custom' => 0, - 'cert_image' => '', - 'status' => 'normal', - 'createtime' => $now - ]); - } + Db::startTrans(); + try { + Db::name('master_skill')->where('master_id', $row['id'])->delete(); - // 4. 处理自定义输入的技能(沉淀入库) - foreach ($customNames as $cName) { - $exist = \think\Db::name('skill_dict')->where('name', $cName)->find(); - if (!$exist) { - $skillDictId = \think\Db::name('skill_dict')->insertGetId([ - 'category_id'=> 0, - 'category' => '其他技能', - 'name' => $cName, - 'source' => 'custom', + foreach ($selectedSkills as $sName) { + $dict = $dictMap[$sName] ?? null; + $old = $existingMap[$sName] ?? null; + Db::name('master_skill')->insert([ 'master_id' => $row['id'], - 'weigh' => 0, + 'cat_id' => $old ? (int)$old['cat_id'] : 0, + 'skill_id' => $dict ? (int)$dict['id'] : ($old ? (int)$old['skill_id'] : 0), + 'skill_name' => $sName, + 'category' => $dict ? $dict['category'] : ($old && !empty($old['category']) ? $old['category'] : '服务技能'), + 'is_custom' => 0, + 'cert_image' => $old ? (string)$old['cert_image'] : '', 'status' => 'normal', - 'createtime' => $now, - 'updatetime' => $now + 'createtime' => $old ? (int)$old['createtime'] : $now, ]); - } else { - $skillDictId = $exist['id']; } - \think\Db::name('master_skill')->insert([ - 'master_id' => $row['id'], - 'cat_id' => 0, - 'skill_id' => $skillDictId, - 'skill_name' => $cName, - 'category' => '其他技能', - 'is_custom' => 1, - 'cert_image' => '', - 'status' => 'normal', - 'createtime' => $now - ]); + foreach ($customNames as $cName) { + $exist = Db::name('skill_dict')->where('name', $cName)->find(); + if (!$exist) { + $skillDictId = Db::name('skill_dict')->insertGetId([ + 'category_id' => 0, + 'category' => '其他技能', + 'name' => $cName, + 'source' => 'custom', + 'master_id' => $row['id'], + 'weigh' => 0, + 'status' => 'normal', + 'createtime' => $now, + 'updatetime' => $now, + ]); + } else { + $skillDictId = (int)$exist['id']; + } + $old = $existingMap[$cName] ?? null; + Db::name('master_skill')->insert([ + 'master_id' => $row['id'], + 'cat_id' => $old ? (int)$old['cat_id'] : 0, + 'skill_id' => $skillDictId, + 'skill_name' => $cName, + 'category' => $exist && !empty($exist['category']) ? $exist['category'] : '其他技能', + 'is_custom' => 1, + 'cert_image' => $old ? (string)$old['cert_image'] : '', + 'status' => 'normal', + 'createtime' => $old ? (int)$old['createtime'] : $now, + ]); + } + + Db::commit(); + } catch (Exception $e) { + Db::rollback(); + $this->error($e->getMessage()); } $this->success('技能配置更新成功'); } - // 读取技能字典全部分类与技能 - $categories = \think\Db::name('skill_category')->where('status', 'normal')->order('weigh desc')->select(); - $allDictSkills = \think\Db::name('skill_dict')->where('status', 'normal')->order('weigh desc')->select(); - + $categories = Db::name('skill_category')->where('status', 'normal')->order('weigh desc')->select(); + $allDictSkills = Db::name('skill_dict')->where('status', 'normal')->order('weigh desc')->select(); + $catTree = []; foreach ($categories as $cat) { $catTree[$cat['name']] = []; @@ -171,12 +340,11 @@ class Master extends Backend $catTree[$cName][] = $item; } - // 读取当前师傅已拥有的技能 - $mySkills = \think\Db::name('master_skill')->where('master_id', $row['id'])->select(); + $mySkills = Db::name('master_skill')->where('master_id', $row['id'])->select(); $selectedSkills = []; $customArr = []; foreach ($mySkills as $ms) { - if ($ms['is_custom'] == 1) { + if ((int)$ms['is_custom'] === 1 && !Db::name('skill_dict')->where('name', $ms['skill_name'])->where('source', '<>', 'custom')->count()) { $customArr[] = $ms['skill_name']; } else { $selectedSkills[] = $ms['skill_name']; @@ -195,45 +363,78 @@ class Master extends Backend */ public function detail($ids = null) { - $row = $this->model->with(['user'])->find($ids); + $row = $this->model->with(['user'])->where('master.id', (int)$ids)->find(); if (!$row) { $this->error(__('No Results were found')); } + $orderModel = new \app\admin\model\Order; + $statusMap = $orderModel->getStatusList(); + // 1. 最近接单记录 - $recentOrders = \think\Db::name('order') + $recentOrders = Db::name('order') + ->where('master_id', $row['id']) + ->order('id desc') + ->limit(8) + ->select(); + foreach ($recentOrders as &$o) { + $o['status_label'] = $statusMap[(int)$o['status']] ?? ('状态' . $o['status']); + } + unset($o); + + // 2. 提现流水 + $recentWithdraws = Db::name('withdraw') ->where('master_id', $row['id']) ->order('id desc') ->limit(6) ->select(); - // 2. 提现流水 - $recentWithdraws = \think\Db::name('withdraw') - ->where('master_id', $row['id']) - ->order('id desc') + // 3. 工单佣金结算记录 + $recentSettles = Db::name('settle') + ->alias('s') + ->join('order o', 'o.id = s.order_id', 'LEFT') + ->where('s.master_id', $row['id']) + ->field('s.*, o.order_no, o.service_name') + ->order('s.id desc') + ->limit(6) + ->select(); + + // 4. 客户评价记录 + $recentReviews = Db::name('review') + ->alias('r') + ->join('order o', 'o.id = r.order_id', 'LEFT') + ->join('user u', 'u.id = r.user_id', 'LEFT') + ->where('r.master_id', $row['id']) + ->field('r.*, o.order_no, o.service_name, u.nickname as user_nickname') + ->order('r.id desc') ->limit(5) ->select(); - // 3. 服务好评记录 - $recentReviews = \think\Db::name('review') - ->where('master_id', $row['id']) - ->order('id desc') - ->limit(5) - ->select(); - - // 4. 服务技能与认证证书 - $skills = \think\Db::name('master_skill') + // 5. 服务技能与认证证书 + $skills = Db::name('master_skill') ->alias('s') ->join('service_cat c', 'c.id = s.cat_id', 'LEFT') ->where('s.master_id', $row['id']) ->field('s.*, c.name as cat_name') ->select(); - $this->view->assign('row', $row); - $this->view->assign('recentOrders', $recentOrders); - $this->view->assign('recentWithdraws', $recentWithdraws); - $this->view->assign('recentReviews', $recentReviews); - $this->view->assign('skills', $skills); + // 6. 钱包资金变动流水 + $walletLogs = Db::name('wallet_log') + ->where('user_type', 'master') + ->where('user_id', $row['id']) + ->order('id desc') + ->limit(6) + ->select(); + + $this->view->assign([ + 'row' => $row, + 'recentOrders' => $recentOrders, + 'recentWithdraws' => $recentWithdraws, + 'recentSettles' => $recentSettles, + 'recentReviews' => $recentReviews, + 'skills' => $skills, + 'walletLogs' => $walletLogs, + ]); return $this->view->fetch(); } diff --git a/server/application/admin/controller/Merchant.php b/server/application/admin/controller/Merchant.php index 65167be..72a098a 100644 --- a/server/application/admin/controller/Merchant.php +++ b/server/application/admin/controller/Merchant.php @@ -3,20 +3,24 @@ namespace app\admin\controller; use app\common\controller\Backend; +use app\common\library\InvoiceService; +use think\Db; +use Exception; /** - * 商家档案 + * 合作商家与企业档案管理 * - * @icon fa fa-circle-o + * @icon fa fa-building-o */ class Merchant extends Backend { - /** * Merchant模型对象 * @var \app\admin\model\Merchant */ protected $model = null; + protected $relationSearch = true; + protected $searchFields = 'id,name,license_no,contact,mobile'; public function _initialize() { @@ -26,8 +30,6 @@ class Merchant extends Backend $this->view->assign("verifyStatusList", $this->model->getVerifyStatusList()); } - protected $relationSearch = true; - public function index() { $this->request->filter(['strip_tags', 'trim']); @@ -43,29 +45,322 @@ class Merchant extends Backend ->where($where) ->order($sort, $order) ->paginate($limit); - $result = ['total' => $list->total(), 'rows' => $list->items()]; - return json($result); + + $rows = $list->items(); + if (!empty($rows)) { + $mIds = array_column($rows, 'id'); + + // 1. 查询子账号归属映射 + $members = Db::name('merchant_member')->where('merchant_id', 'in', $mIds)->select(); + $mUidsMap = []; + $memberCntMap = []; + foreach ($rows as $r) { + $mid = (int)$r['id']; + $mUidsMap[$mid] = (int)$r['user_id'] > 0 ? [(int)$r['user_id']] : []; + $memberCntMap[$mid] = 0; + } + foreach ($members as $mb) { + $mid = (int)$mb['merchant_id']; + $uid = (int)$mb['user_id']; + $memberCntMap[$mid] = ($memberCntMap[$mid] ?? 0) + 1; + if ($uid > 0 && !in_array($uid, $mUidsMap[$mid], true)) { + $mUidsMap[$mid][] = $uid; + } + } + + // 2. 汇总所有可能关联订单(通过 merchant_order 或商户主/子账号下单) + $allUids = []; + foreach ($mUidsMap as $uArr) { + foreach ($uArr as $u) { + $allUids[$u] = $u; + } + } + $moRows = Db::name('merchant_order')->where('merchant_id', 'in', $mIds)->select(); + $moOrderIds = array_column($moRows, 'order_id'); + $orderToMid = []; + foreach ($moRows as $mo) { + $orderToMid[(int)$mo['order_id']] = (int)$mo['merchant_id']; + } + + $uidToMid = []; + foreach ($mUidsMap as $mid => $uArr) { + foreach ($uArr as $u) { + $uidToMid[$u] = $mid; + } + } + + $orderQuery = Db::name('order')->field('id,user_id,pay_amount,pay_status,status'); + if (!empty($allUids) && !empty($moOrderIds)) { + $orderQuery->where(function ($q) use ($allUids, $moOrderIds) { + $q->where('user_id', 'in', array_values($allUids)) + ->whereOr('id', 'in', $moOrderIds); + }); + $relOrders = $orderQuery->select(); + } elseif (!empty($allUids)) { + $relOrders = $orderQuery->where('user_id', 'in', array_values($allUids))->select(); + } elseif (!empty($moOrderIds)) { + $relOrders = $orderQuery->where('id', 'in', $moOrderIds)->select(); + } else { + $relOrders = []; + } + + $statCnt = []; + $statAmt = []; + foreach ($relOrders as $ord) { + $oid = (int)$ord['id']; + $mid = $orderToMid[$oid] ?? ($uidToMid[(int)$ord['user_id']] ?? 0); + if ($mid <= 0) continue; + $statCnt[$mid] = ($statCnt[$mid] ?? 0) + 1; + if ((int)$ord['pay_status'] === 1 && (int)$ord['status'] !== 9) { + $statAmt[$mid] = round(($statAmt[$mid] ?? 0) + (float)$ord['pay_amount'], 2); + } + } + + foreach ($rows as &$r) { + $mid = (int)$r['id']; + $r['order_count'] = $statCnt[$mid] ?? (int)$r['order_count']; + $r['total_amount'] = isset($statAmt[$mid]) ? number_format($statAmt[$mid], 2, '.', '') : number_format((float)$r['total_amount'], 2, '.', ''); + $r['member_count'] = $memberCntMap[$mid] ?? 0; + } + unset($r); + } + + return json(['total' => $list->total(), 'rows' => $rows]); } /** - * 商户企业全景资质 + * 新增合作企业档案(防重复绑定同一用户,审核通过自动同步默认企业发票抬头) + */ + public function add() + { + if (false === $this->request->isPost()) { + return $this->view->fetch(); + } + $params = $this->request->post('row/a', []); + if (!$params) { + $this->error(__('Parameter %s can not be empty', '')); + } + $params = $this->preExcludeFields($params); + $uid = (int)($params['user_id'] ?? 0); + if ($uid <= 0) { + $this->error('请选择关联的平台注册用户账号'); + } + if (Db::name('merchant')->where('user_id', $uid)->count() > 0) { + $this->error('该用户账号已绑定企业商家主体,请勿重复创建'); + } + $params['order_count'] = 0; + $params['total_amount'] = 0.00; + if (($params['verify_status'] ?? '') === 'reject' && empty(trim((string)($params['audit_msg'] ?? '')))) { + $this->error('驳回企业入驻时请填写审核驳回原因'); + } + + $result = false; + Db::startTrans(); + try { + $result = $this->model->allowField(true)->save($params); + if (($params['verify_status'] ?? '') === 'pass' && $uid > 0) { + $ctx = InvoiceService::resolveContext($uid); + InvoiceService::ensureMerchantDefaultTitle($ctx); + } + Db::commit(); + } catch (Exception $e) { + Db::rollback(); + $this->error($e->getMessage()); + } + if ($result !== false) { + $this->success(); + } + $this->error($this->model->getError() ?: __('No rows were inserted')); + } + + /** + * 删除保护:已产生企业批次订单或开票记录的商户禁止物理删除 + */ + public function del($ids = null) + { + if (false === $this->request->isPost()) { + $this->error(__('Invalid parameters')); + } + $ids = $ids ?: $this->request->post('ids'); + $idArr = array_values(array_filter(array_map('intval', explode(',', (string)$ids)))); + if (!$idArr) { + $this->error(__('Parameter %s can not be empty', 'ids')); + } + $hasOrder = Db::name('merchant_order')->where('merchant_id', 'in', $idArr)->count(); + $hasInvoice = Db::name('invoice')->where('merchant_id', 'in', $idArr)->count(); + if ($hasOrder > 0 || $hasInvoice > 0) { + $this->error('所选企业已关联历史发包订单或开票记录,禁止物理删除'); + } + Db::startTrans(); + try { + Db::name('merchant_member')->where('merchant_id', 'in', $idArr)->delete(); + $deleted = Db::name('merchant')->where('id', 'in', $idArr)->delete(); + Db::commit(); + } catch (Exception $e) { + Db::rollback(); + $this->error($e->getMessage()); + } + if ($deleted) { + $this->success(); + } + $this->error(__('No rows were deleted')); + } + + /** + * 编辑企业档案与入驻审核(审核通过自动同步企业默认发票抬头并发送通知) + */ + public function edit($ids = null) + { + $row = $this->model->get($ids); + if (!$row) { + $this->error(__('No Results were found')); + } + $oldVerify = (string)$row['verify_status']; + + if ($this->request->isPost()) { + $params = $this->request->post("row/a"); + if ($params) { + $params = $this->preExcludeFields($params); + unset($params['order_count'], $params['total_amount']); + if (isset($params['city']) && trim((string)$params['city']) === '' && !empty($row['city'])) { + unset($params['city']); + } + if (isset($params['verify_status']) && $params['verify_status'] === 'reject' && empty(trim((string)($params['audit_msg'] ?? '')))) { + $this->error('驳回企业入驻时请填写审核驳回原因'); + } + $result = false; + Db::startTrans(); + try { + $result = $row->allowField(true)->save($params); + $newVerify = isset($params['verify_status']) ? (string)$params['verify_status'] : $oldVerify; + if ($newVerify !== $oldVerify && in_array($newVerify, ['pass', 'reject'], true)) { + if ($newVerify === 'pass' && (int)$row['user_id'] > 0) { + $ctx = InvoiceService::resolveContext((int)$row['user_id']); + InvoiceService::ensureMerchantDefaultTitle($ctx); + } + if ((int)$row['user_id'] > 0) { + Db::name('message')->insert([ + 'receive_type' => 'user', + 'receive_id' => (int)$row['user_id'], + 'type' => 'audit', + 'title' => $newVerify === 'pass' ? '企业商家认证已通过' : '企业商家认证未通过', + 'content' => $newVerify === 'pass' + ? ('您提交的企业【' . $row['name'] . '】资质审核已通过,现可使用商家工作台批量派单与开票服务。') + : ('企业【' . $row['name'] . '】资质审核未通过,原因:' . (trim((string)($params['audit_msg'] ?? '')) ?: '请核对营业执照信息后重新提交')), + 'order_id' => 0, + 'is_read' => 0, + 'createtime' => time(), + ]); + } + } + Db::commit(); + } catch (Exception $e) { + Db::rollback(); + $this->error($e->getMessage()); + } + if ($result !== false) { + $this->success(); + } + $this->error(__('No rows were updated')); + } + $this->error(__('Parameter %s can not be empty', '')); + } + + $this->view->assign("row", $row); + return $this->view->fetch(); + } + + /** + * 商户企业全景资质与业务协同看板 */ public function detail($ids = null) { - $row = $this->model->with(['user'])->find($ids); + $row = $this->model->with(['user'])->where('merchant.id', (int)$ids)->find(); if (!$row) { $this->error(__('No Results were found')); } - // 查询商户派发的工单记录 - $recentOrders = \think\Db::name('order') - ->where('user_id', $row['user_id']) + $mid = (int)$row['id']; + $uids = (int)$row['user_id'] > 0 ? [(int)$row['user_id']] : []; + + // 1. 企业员工协同子账号 + $members = Db::name('merchant_member') + ->alias('mm') + ->join('user u', 'u.id = mm.user_id', 'LEFT') + ->where('mm.merchant_id', $mid) + ->field('mm.*, u.nickname, u.username, u.mobile') + ->order('mm.id desc') + ->select(); + foreach ($members as $mb) { + if ((int)$mb['user_id'] > 0 && !in_array((int)$mb['user_id'], $uids, true)) { + $uids[] = (int)$mb['user_id']; + } + } + + // 2. 商户关联工单统计与最近列表(兼容 merchant_order 与账号下单) + $moOrderIds = Db::name('merchant_order')->where('merchant_id', $mid)->column('order_id'); + $buildOrderScope = function ($q) use ($uids, $moOrderIds) { + if (!empty($uids) && !empty($moOrderIds)) { + $q->where('o.user_id', 'in', $uids)->whereOr('o.id', 'in', $moOrderIds); + } elseif (!empty($uids)) { + $q->where('o.user_id', 'in', $uids); + } elseif (!empty($moOrderIds)) { + $q->where('o.id', 'in', $moOrderIds); + } else { + $q->where('o.id', 0); + } + }; + + $orderAgg = Db::name('order')->alias('o') + ->field('COUNT(*) AS total_cnt, SUM(CASE WHEN o.pay_status = 1 AND o.status <> 9 THEN o.pay_amount ELSE 0 END) AS total_gmv') + ->where($buildOrderScope) + ->find(); + + $statusMap = (new \app\admin\model\Order)->getStatusList(); + $recentOrders = Db::name('order')->alias('o') + ->join('master m', 'm.id = o.master_id', 'LEFT') + ->join('merchant_order mo', 'mo.order_id = o.id', 'LEFT') + ->where($buildOrderScope) + ->field('o.*, m.realname as master_name, mo.batch_no') + ->order('o.id desc') + ->limit(8) + ->select(); + foreach ($recentOrders as &$o) { + $o['status_label'] = $statusMap[(int)$o['status']] ?? ('状态' . $o['status']); + } + unset($o); + + // 3. 关联发票抬头与历史开票记录 + $titleList = Db::name('invoice_title') + ->where(function ($q) use ($mid, $uids) { + $q->where('merchant_id', $mid); + if (!empty($uids)) { + $q->whereOr('user_id', 'in', $uids); + } + }) + ->order('is_default desc, id desc') + ->select(); + + $invoiceList = Db::name('invoice') + ->where(function ($q) use ($mid, $uids) { + $q->where('merchant_id', $mid); + if (!empty($uids)) { + $q->whereOr('user_id', 'in', $uids); + } + }) ->order('id desc') ->limit(6) ->select(); - $this->view->assign('row', $row); - $this->view->assign('recentOrders', $recentOrders); + $this->view->assign([ + 'row' => $row, + 'members' => $members, + 'recentOrders' => $recentOrders, + 'titleList' => $titleList, + 'invoiceList' => $invoiceList, + 'orderCount' => (int)($orderAgg['total_cnt'] ?? 0), + 'orderGmv' => number_format((float)($orderAgg['total_gmv'] ?? 0), 2, '.', ''), + ]); return $this->view->fetch(); } diff --git a/server/application/admin/controller/Message.php b/server/application/admin/controller/Message.php index f01a1e7..4593b64 100644 --- a/server/application/admin/controller/Message.php +++ b/server/application/admin/controller/Message.php @@ -3,20 +3,22 @@ namespace app\admin\controller; use app\common\controller\Backend; +use think\Db; +use think\exception\PDOException; /** - * 消息中心 + * 消息通知中心 * - * @icon fa fa-circle-o + * @icon fa fa-bell-o */ class Message extends Backend { - /** * Message模型对象 * @var \app\admin\model\Message */ protected $model = null; + protected $searchFields = 'id,title,content'; public function _initialize() { @@ -26,13 +28,189 @@ class Message extends Backend $this->view->assign("typeList", $this->model->getTypeList()); } + /** + * 消息列表(批量补齐接收人真实姓名/昵称、手机号及关联工单号) + */ + 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(); + $userIds = []; + $masterIds = []; + $orderIds = []; + foreach ($rows as $r) { + if ($r['receive_type'] === 'master' && (int)$r['receive_id'] > 0) { + $masterIds[] = (int)$r['receive_id']; + } elseif ($r['receive_type'] === 'user' && (int)$r['receive_id'] > 0) { + $userIds[] = (int)$r['receive_id']; + } + if ((int)$r['order_id'] > 0) { + $orderIds[] = (int)$r['order_id']; + } + } + + $userMap = $userIds + ? Db::name('user')->where('id', 'in', array_unique($userIds))->column('id,nickname,username,mobile', 'id') + : []; + $masterMap = $masterIds + ? Db::name('master')->where('id', 'in', array_unique($masterIds))->column('id,realname,mobile', 'id') + : []; + $orderMap = $orderIds + ? Db::name('order')->where('id', 'in', array_unique($orderIds))->column('order_no', 'id') + : []; + + foreach ($rows as &$item) { + $rid = (int)$item['receive_id']; + if ($item['receive_type'] === 'master') { + $m = $masterMap[$rid] ?? null; + $item['receiver_name'] = $m ? ($m['realname'] ?: ('师傅#' . $rid)) : ('师傅#' . $rid); + $item['receiver_mobile'] = $m['mobile'] ?? ''; + } else { + $u = $userMap[$rid] ?? null; + $item['receiver_name'] = $u ? ($u['nickname'] ?: ($u['username'] ?: ('用户#' . $rid))) : ('用户#' . $rid); + $item['receiver_mobile'] = $u['mobile'] ?? ''; + } + $oid = (int)$item['order_id']; + $item['order_no'] = $oid > 0 ? ($orderMap[$oid] ?? '') : ''; + } + unset($item); + + return json(['total' => $list->total(), 'rows' => $rows]); + } /** - * 默认生成的控制器所继承的父类中有index/add/edit/del/multi五个基础方法、destroy/restore/recyclebin三个回收站方法 - * 因此在当前控制器中可不用编写增删改查的代码,除非需要自己控制这部分逻辑 - * 需要将application/admin/library/traits/Backend.php中对应的方法复制到当前控制器,然后进行修改 + * 消息详情回看 */ + public function detail($ids = null) + { + $row = $this->model->get($ids); + if (!$row) { + $this->error(__('No Results were found')); + } + $receiver = []; + if ($row['receive_type'] === 'master' && (int)$row['receive_id'] > 0) { + $m = Db::name('master')->where('id', (int)$row['receive_id'])->field('id,realname,mobile,city')->find(); + if ($m) { + $receiver = ['name' => $m['realname'], 'mobile' => $m['mobile'], 'extra' => $m['city'] ?: '认证师傅']; + } + } elseif ((int)$row['receive_id'] > 0) { + $u = Db::name('user')->where('id', (int)$row['receive_id'])->field('id,nickname,username,mobile')->find(); + if ($u) { + $receiver = ['name' => $u['nickname'] ?: $u['username'], 'mobile' => $u['mobile'], 'extra' => '平台注册用户']; + } + } + $order = (int)$row['order_id'] > 0 + ? Db::name('order')->where('id', (int)$row['order_id'])->field('id,order_no,service_name,pay_amount,status')->find() + : null; + $this->view->assign([ + 'row' => $row, + 'receiver' => $receiver, + 'order' => $order ?: [], + ]); + return $this->view->fetch(); + } + /** + * 下发站内通知(支持选择单个或多个用户/师傅批量推送) + */ + public function add() + { + if (false === $this->request->isPost()) { + return $this->view->fetch(); + } + $params = $this->request->post('row/a', []); + $receiveType = in_array($params['receive_type'] ?? '', ['user', 'master'], true) ? $params['receive_type'] : 'user'; + $rawIds = $receiveType === 'master' + ? ($params['receive_master_id'] ?? ($params['receive_id'] ?? '')) + : ($params['receive_user_id'] ?? ($params['receive_id'] ?? '')); + + $idArr = array_values(array_unique(array_filter(array_map('intval', explode(',', (string)$rawIds))))); + if (!$idArr) { + $this->error('请至少选择一位消息接收对象'); + } + $title = trim((string)($params['title'] ?? '')); + $content = trim((string)($params['content'] ?? '')); + if ($title === '' || $content === '') { + $this->error('通知标题与正文内容不能为空'); + } + $msgType = in_array($params['type'] ?? '', ['order', 'system', 'audit', 'remind'], true) ? $params['type'] : 'system'; + $orderId = max(0, (int)($params['order_id'] ?? 0)); + $isRead = !empty($params['is_read']) ? 1 : 0; + $now = time(); + + $insertRows = []; + foreach ($idArr as $rid) { + $insertRows[] = [ + 'receive_type' => $receiveType, + 'receive_id' => $rid, + 'type' => $msgType, + 'title' => mb_substr($title, 0, 100), + 'content' => mb_substr($content, 0, 500), + 'order_id' => $orderId, + 'is_read' => $isRead, + 'createtime' => $now, + ]; + } + + Db::startTrans(); + try { + Db::name('message')->insertAll($insertRows); + Db::commit(); + } catch (PDOException|\Exception $e) { + Db::rollback(); + $this->error($e->getMessage()); + } + $this->success('成功发送 ' . count($insertRows) . ' 条站内通知'); + } + + /** + * 编辑站内通知 + */ + 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); + return $this->view->fetch(); + } + $params = $this->request->post('row/a', []); + $receiveType = in_array($params['receive_type'] ?? '', ['user', 'master'], true) ? $params['receive_type'] : $row['receive_type']; + $receiveId = $receiveType === 'master' + ? (int)($params['receive_master_id'] ?? ($params['receive_id'] ?? 0)) + : (int)($params['receive_user_id'] ?? ($params['receive_id'] ?? 0)); + if ($receiveId <= 0) { + $this->error('请选择有效的接收对象'); + } + + $data = [ + 'receive_type' => $receiveType, + 'receive_id' => $receiveId, + 'type' => in_array($params['type'] ?? '', ['order', 'system', 'audit', 'remind'], true) ? $params['type'] : $row['type'], + 'order_id' => max(0, (int)($params['order_id'] ?? 0)), + 'title' => mb_substr(trim((string)($params['title'] ?? '')), 0, 100), + 'content' => mb_substr(trim((string)($params['content'] ?? '')), 0, 500), + 'is_read' => !empty($params['is_read']) ? 1 : 0, + ]; + if ($data['title'] === '' || $data['content'] === '') { + $this->error('通知标题与正文内容不能为空'); + } + $row->save($data); + $this->success(); + } } diff --git a/server/application/admin/controller/Order.php b/server/application/admin/controller/Order.php index 9de61dc..ef5e054 100644 --- a/server/application/admin/controller/Order.php +++ b/server/application/admin/controller/Order.php @@ -3,20 +3,23 @@ namespace app\admin\controller; use app\common\controller\Backend; +use think\Db; +use Exception; /** - * 订单 + * 订单管理与调度中心 * - * @icon fa fa-circle-o + * @icon fa fa-file-text-o */ class Order extends Backend { - /** * Order模型对象 * @var \app\admin\model\Order */ protected $model = null; + protected $relationSearch = true; + protected $searchFields = 'id,order_no,service_name,contact,mobile'; public function _initialize() { @@ -31,8 +34,6 @@ class Order extends Backend $this->view->assign("urgentList", $this->model->getUrgentList()); } - protected $relationSearch = true; - public function index() { $this->request->filter(['strip_tags', 'trim']); @@ -48,8 +49,430 @@ class Order extends Backend ->where($where) ->order($sort, $order) ->paginate($limit); - $result = ['total' => $list->total(), 'rows' => $list->items()]; - return json($result); + + $rows = $list->items(); + if (!empty($rows)) { + $orderIds = array_column($rows, 'id'); + $merLinks = Db::name('merchant_order') + ->alias('mo') + ->join('merchant m', 'm.id = mo.merchant_id', 'LEFT') + ->where('mo.order_id', 'in', $orderIds) + ->field('mo.order_id, mo.batch_no, m.name as merchant_name') + ->select(); + $merMap = []; + foreach ($merLinks as $ml) { + $merMap[(int)$ml['order_id']] = $ml; + } + foreach ($rows as &$r) { + $oid = (int)$r['id']; + $r['merchant_name'] = isset($merMap[$oid]) ? (string)$merMap[$oid]['merchant_name'] : ''; + $r['batch_no'] = isset($merMap[$oid]) ? (string)$merMap[$oid]['batch_no'] : ''; + } + unset($r); + } + + return json(['total' => $list->total(), 'rows' => $rows]); + } + + /** + * 禁止后台手工凭空新增订单 + */ + public function add() + { + $this->error('订单须由客户端或企业商家工作台真实发起,不支持后台手工伪造'); + } + + /** + * 禁止物理删除业务订单 + */ + public function del($ids = "") + { + $this->error('订单关联资金流水与结算凭证,禁止物理删除;如需终止履约请编辑取消工单'); + } + + /** + * 编辑订单(支持后台派单/改派师傅通知与轨迹联动,以及取消订单自动退回优惠券) + */ + public function edit($ids = null) + { + $row = $this->model->with(['user', 'master'])->where('order.id', (int)$ids)->find(); + if (!$row) { + $this->error(__('No Results were found')); + } + if (false === $this->request->isPost()) { + $catName = (int)$row['cat_id'] > 0 ? (string)Db::name('service_cat')->where('id', (int)$row['cat_id'])->value('name') : ''; + $this->view->assign('catName', $catName); + $this->view->assign('row', $row); + return $this->view->fetch(); + } + + $row = $this->model->get($ids); + $params = $this->request->post('row/a', []); + if (!$params) { + $this->error(__('Parameter %s can not be empty', '')); + } + $params = $this->preExcludeFields($params); + + // 锁定订单归属与财务结算核心字段,禁止后台编辑工单时意外篡改账务 + unset( + $params['order_no'], + $params['user_id'], + $params['cat_id'], + $params['service_id'], + $params['price_type'], + $params['base_amount'], + $params['distance_amount'], + $params['floor_amount'], + $params['diff_amount'], + $params['discount_amount'], + $params['total_amount'], + $params['pay_amount'], + $params['pay_status'], + $params['pay_type'], + $params['pay_time'], + $params['settle_status'], + $params['settle_amount'], + $params['coupon_id'], + $params['verify_code'] + ); + + // 统一转换时间节点字段(留空转为 0,日期字符串转为 Unix 时间戳) + foreach (['appoint_time', 'accept_time', 'arrive_time', 'start_time', 'finish_time'] as $tField) { + if (array_key_exists($tField, $params)) { + $tv = trim((string)$params[$tField]); + $params[$tField] = $tv === '' ? 0 : (is_numeric($tv) ? (int)$tv : ((int)strtotime($tv) ?: 0)); + } + } + if (array_key_exists('master_id', $params) && trim((string)$params['master_id']) === '') { + $params['master_id'] = 0; + } + + $oldMasterId = (int)$row['master_id']; + $oldStatus = (int)$row['status']; + $newMasterId = isset($params['master_id']) ? (int)$params['master_id'] : $oldMasterId; + $newStatus = isset($params['status']) ? (int)$params['status'] : $oldStatus; + $now = time(); + + // 若 city-picker 提交空串而原库有城市,保留原城市 + if (isset($params['city']) && trim((string)$params['city']) === '' && !empty($row['city'])) { + unset($params['city']); + } + + $targetMaster = null; + if ($newMasterId > 0 && $newMasterId !== $oldMasterId && $newStatus !== 9) { + $targetMaster = Db::name('master')->where('id', $newMasterId)->find(); + if (!$targetMaster || $targetMaster['verify_status'] !== 'pass') { + $this->error('所选承接师傅不存在或尚未通过实名认证'); + } + if (in_array($newStatus, [1, 2], true)) { + $newStatus = 3; + $params['status'] = 3; + } + if (empty($params['accept_time']) || (string)$params['accept_time'] === (string)$row['accept_time']) { + $params['accept_time'] = $now; + } + } + + $result = false; + Db::startTrans(); + try { + $result = $row->allowField(true)->save($params); + + // 1. 后台通过编辑指派或改派了师傅:写轨迹日志与三方站内信 + if ($targetMaster && $newMasterId !== $oldMasterId && $newStatus !== 9) { + $actionLabel = $oldMasterId > 0 ? '平台编辑改派师傅' : '平台编辑指派师傅'; + $logRemark = $actionLabel . ':指派给【' . $targetMaster['realname'] . '(' . $targetMaster['mobile'] . ')】'; + Db::name('order_log')->insert([ + 'order_id' => (int)$row['id'], + 'status' => $newStatus, + 'remark' => mb_substr($logRemark, 0, 255), + 'operator_type' => 'admin', + 'operator_id' => (int)$this->auth->id, + 'createtime' => $now, + ]); + + Db::name('message')->insert([ + 'receive_type' => 'master', + 'receive_id' => $newMasterId, + 'type' => 'order', + 'title' => '平台派单通知', + 'content' => '平台已为您指派工单【' . $row['order_no'] . '】(' . $row['service_name'] . '),请及时联系客户预约上门。', + 'order_id' => (int)$row['id'], + 'is_read' => 0, + 'createtime' => $now, + ]); + + if ($oldMasterId > 0 && $oldMasterId !== $newMasterId) { + Db::name('message')->insert([ + 'receive_type' => 'master', + 'receive_id' => $oldMasterId, + 'type' => 'order', + 'title' => '工单改派通知', + 'content' => '工单【' . $row['order_no'] . '】已由平台调度改派给其他师傅承接。', + 'order_id' => (int)$row['id'], + 'is_read' => 0, + 'createtime' => $now, + ]); + } + + if ((int)$row['user_id'] > 0) { + Db::name('message')->insert([ + 'receive_type' => 'user', + 'receive_id' => (int)$row['user_id'], + 'type' => 'order', + 'title' => '师傅指派成功通知', + 'content' => '您的订单【' . $row['order_no'] . '】已安排' . $targetMaster['realname'] . '师傅(' . $targetMaster['mobile'] . ')为您服务。', + 'order_id' => (int)$row['id'], + 'is_read' => 0, + 'createtime' => $now, + ]); + } + } + + // 2. 后台将订单变更为取消状态(status=9):自动退回已锁定的优惠券并记录轨迹通知 + if ($oldStatus !== 9 && $newStatus === 9) { + $this->refundLockedCoupon((int)$row['id'], (int)$row['coupon_id'], $now); + + $cancelReason = trim((string)($params['cancel_reason'] ?? $row['cancel_reason'] ?? '')); + Db::name('order_log')->insert([ + 'order_id' => (int)$row['id'], + 'status' => 9, + 'remark' => mb_substr('平台运营后台取消工单' . ($cancelReason !== '' ? (':' . $cancelReason) : ''), 0, 255), + 'operator_type' => 'admin', + 'operator_id' => (int)$this->auth->id, + 'createtime' => $now, + ]); + + if ($oldMasterId > 0) { + Db::name('message')->insert([ + 'receive_type' => 'master', + 'receive_id' => $oldMasterId, + 'type' => 'order', + 'title' => '工单已取消通知', + 'content' => '工单【' . $row['order_no'] . '】已由平台取消' . ($cancelReason !== '' ? (',原因:' . $cancelReason) : '') . '。', + 'order_id' => (int)$row['id'], + 'is_read' => 0, + 'createtime' => $now, + ]); + } + if ((int)$row['user_id'] > 0) { + Db::name('message')->insert([ + 'receive_type' => 'user', + 'receive_id' => (int)$row['user_id'], + 'type' => 'order', + 'title' => '订单取消通知', + 'content' => '您的订单【' . $row['order_no'] . '】已取消' . ($cancelReason !== '' ? (',原因:' . $cancelReason) : '') . ',如使用了优惠券已自动退回账户。', + 'order_id' => (int)$row['id'], + 'is_read' => 0, + 'createtime' => $now, + ]); + } + } elseif ($oldStatus !== $newStatus && !($targetMaster && $newMasterId !== $oldMasterId)) { + $statusMap = $this->model->getStatusList(); + Db::name('order_log')->insert([ + 'order_id' => (int)$row['id'], + 'status' => $newStatus, + 'remark' => '平台运营变更工单状态为:' . ($statusMap[$newStatus] ?? ('状态' . $newStatus)), + 'operator_type' => 'admin', + 'operator_id' => (int)$this->auth->id, + 'createtime' => $now, + ]); + } + + Db::commit(); + } catch (Exception $e) { + Db::rollback(); + $this->error($e->getMessage()); + } + + if ($result !== false) { + $this->success(); + } + $this->error(__('No rows were updated')); + } + + /** + * 订单取消时退回已锁定的用户优惠券 + */ + protected function refundLockedCoupon($orderId, $userCouponId, $now) + { + $ucRows = Db::name('user_coupon') + ->where(function ($q) use ($orderId, $userCouponId) { + if ($userCouponId > 0) { + $q->where('id', $userCouponId)->whereOr('order_id', $orderId); + } else { + $q->where('order_id', $orderId); + } + }) + ->where('status', 'used') + ->select(); + + foreach ($ucRows as $uc) { + $couponTpl = Db::name('coupon')->where('id', (int)$uc['coupon_id'])->find(); + $isExpired = $couponTpl && (int)$couponTpl['end_time'] > 0 && (int)$couponTpl['end_time'] < $now; + Db::name('user_coupon')->where('id', (int)$uc['id'])->update([ + 'status' => $isExpired ? 'expired' : 'unused', + 'order_id' => 0, + 'use_time' => null, + ]); + } + } + + /** + * 人工指派 / 改派师傅 + */ + public function dispatch($ids = null) + { + $row = $this->model->with(['user', 'master'])->where('order.id', (int)$ids)->find(); + if (!$row) { + $this->error(__('No Results were found')); + } + + if ($this->request->isPost()) { + if (in_array((int)$row['status'], [8, 9], true)) { + $this->error('已完成或已取消的工单不支持指派/改派'); + } + if ((int)$row['pay_status'] !== 1 && (int)$row['status'] === 0) { + $this->error('该工单尚未完成支付,无法派单'); + } + + $masterId = (int)$this->request->post('master_id', 0); + $remark = trim((string)$this->request->post('dispatch_remark', '')); + if ($masterId <= 0) { + $this->error('请选择要指派的认证师傅'); + } + + $targetMaster = Db::name('master')->where('id', $masterId)->find(); + if (!$targetMaster || $targetMaster['verify_status'] !== 'pass') { + $this->error('所选师傅不存在或未通过实名认证'); + } + $oldMasterId = (int)$row['master_id']; + if ($oldMasterId === $masterId && (int)$row['status'] >= 3) { + $this->error('当前工单已由该师傅承接,无需重复指派'); + } + + $now = time(); + $newStatus = in_array((int)$row['status'], [1, 2], true) ? 3 : (int)$row['status']; + $actionLabel = $oldMasterId > 0 ? '平台改派师傅' : '平台人工指派师傅'; + $logRemark = $actionLabel . ':指派给【' . $targetMaster['realname'] . '(' . $targetMaster['mobile'] . ')】' . ($remark !== '' ? (',备注:' . $remark) : ''); + + Db::startTrans(); + try { + $updateData = [ + 'master_id' => $masterId, + 'status' => $newStatus, + 'updatetime' => $now, + ]; + if (empty($row['accept_time']) || $oldMasterId !== $masterId) { + $updateData['accept_time'] = $now; + } + Db::name('order')->where('id', $row['id'])->update($updateData); + + Db::name('order_log')->insert([ + 'order_id' => $row['id'], + 'status' => $newStatus, + 'remark' => mb_substr($logRemark, 0, 255), + 'operator_type' => 'admin', + 'operator_id' => (int)$this->auth->id, + 'createtime' => $now, + ]); + + // 通知新接单师傅 + Db::name('message')->insert([ + 'receive_type' => 'master', + 'receive_id' => $masterId, + 'type' => 'order', + 'title' => '平台派单通知', + 'content' => '平台已为您指派工单【' . $row['order_no'] . '】(' . $row['service_name'] . '),请及时联系客户预约上门。', + 'order_id' => (int)$row['id'], + 'is_read' => 0, + 'createtime' => $now, + ]); + + // 若为改派,通知原师傅 + if ($oldMasterId > 0 && $oldMasterId !== $masterId) { + Db::name('message')->insert([ + 'receive_type' => 'master', + 'receive_id' => $oldMasterId, + 'type' => 'order', + 'title' => '工单改派通知', + 'content' => '工单【' . $row['order_no'] . '】已由平台调度改派给其他师傅承接。', + 'order_id' => (int)$row['id'], + 'is_read' => 0, + 'createtime' => $now, + ]); + } + + // 通知下单业主 + if ((int)$row['user_id'] > 0) { + Db::name('message')->insert([ + 'receive_type' => 'user', + 'receive_id' => (int)$row['user_id'], + 'type' => 'order', + 'title' => '师傅指派成功通知', + 'content' => '您的订单【' . $row['order_no'] . '】已安排' . $targetMaster['realname'] . '师傅(' . $targetMaster['mobile'] . ')为您服务。', + 'order_id' => (int)$row['id'], + 'is_read' => 0, + 'createtime' => $now, + ]); + } + + Db::commit(); + } catch (Exception $e) { + Db::rollback(); + $this->error($e->getMessage()); + } + + $this->success('派单调度成功'); + } + + // 推荐候选师傅:优先同城在线且实名通过的师傅 + $cityKeyword = ''; + if (!empty($row['city'])) { + $parts = explode('/', (string)$row['city']); + $cityKeyword = trim((string)(count($parts) > 1 ? $parts[1] : $parts[0])); + $cityKeyword = preg_replace('/市$/u', '', $cityKeyword); + } + + $candidateMasters = Db::name('master') + ->where('verify_status', 'pass') + ->orderRaw("work_status = 'on' DESC, score DESC, order_count DESC") + ->limit(12) + ->select(); + + if ($cityKeyword !== '') { + $sameCity = []; + $otherCity = []; + foreach ($candidateMasters as $cm) { + if (mb_strpos((string)$cm['city'], $cityKeyword) !== false) { + $sameCity[] = $cm; + } else { + $otherCity[] = $cm; + } + } + $candidateMasters = array_slice(array_merge($sameCity, $otherCity), 0, 8); + } else { + $candidateMasters = array_slice($candidateMasters, 0, 8); + } + + $cmIds = array_column($candidateMasters, 'id'); + $cmSkillMap = []; + if (!empty($cmIds)) { + $skRows = Db::name('master_skill')->where('master_id', 'in', $cmIds)->where('status', 'normal')->select(); + foreach ($skRows as $sk) { + if (!empty($sk['skill_name'])) { + $cmSkillMap[(int)$sk['master_id']][] = $sk['skill_name']; + } + } + } + foreach ($candidateMasters as &$cm) { + $cm['skills'] = array_slice($cmSkillMap[(int)$cm['id']] ?? [], 0, 3); + } + unset($cm); + + $this->view->assign('row', $row); + $this->view->assign('candidateMasters', $candidateMasters); + return $this->view->fetch(); } /** @@ -57,42 +480,90 @@ class Order extends Backend */ public function detail($ids = null) { - $row = $this->model->with(['user', 'master'])->find($ids); + $row = $this->model->with(['user', 'master'])->where('order.id', (int)$ids)->find(); if (!$row) { $this->error(__('No Results were found')); } // 1. 多商品/多工项明细 - $goodsList = \think\Db::name('order_goods')->where('order_id', $row['id'])->select(); + $goodsList = Db::name('order_goods')->where('order_id', $row['id'])->select(); - // 2. 现场施工取证图片 (区分报损、过程、完工) - $mediaList = \think\Db::name('order_media')->where('order_id', $row['id'])->order('id asc')->select(); + // 2. 现场施工取证图片 (区分签到、过程、完工、报损) + $mediaList = Db::name('order_media')->where('order_id', $row['id'])->order('id asc')->select(); // 3. 履约轨迹日志 - $logList = \think\Db::name('order_log')->where('order_id', $row['id'])->order('createtime asc')->select(); + $statusMap = $this->model->getStatusList(); + $logList = Db::name('order_log')->where('order_id', $row['id'])->order('createtime asc, id asc')->select(); + foreach ($logList as &$lg) { + $lg['status_label'] = isset($statusMap[(int)$lg['status']]) ? $statusMap[(int)$lg['status']] : '状态变更'; + } + unset($lg); // 4. 师傅报价列表 - $quoteList = \think\Db::name('order_quote') + $quoteList = Db::name('order_quote') ->alias('q') - ->join('fa_master m', 'q.master_id = m.id', 'LEFT') + ->join('master m', 'q.master_id = m.id', 'LEFT') ->field('q.*, m.realname as master_name, m.mobile as master_mobile, m.score as master_score') ->where('q.order_id', $row['id']) ->order('q.id desc') ->select(); - // 5. 客户评价与回复 - $review = \think\Db::name('review')->where('order_id', $row['id'])->find(); + // 5. 客户评价与回复(兼容 JSON 数组或逗号分隔标签) + $review = Db::name('review')->where('order_id', $row['id'])->find(); + if ($review) { + $tagsArr = []; + if (!empty($review['tags'])) { + $decoded = json_decode($review['tags'], true); + if (is_array($decoded)) { + $tagsArr = array_filter($decoded); + } else { + $tagsArr = array_filter(explode(',', (string)$review['tags'])); + } + } + $review['tags_arr'] = $tagsArr; + } // 6. 财务结算记录 - $settle = \think\Db::name('settle')->where('order_id', $row['id'])->find(); + $settle = Db::name('settle')->where('order_id', $row['id'])->find(); - $this->view->assign('row', $row); - $this->view->assign('goodsList', $goodsList); - $this->view->assign('mediaList', $mediaList); - $this->view->assign('logList', $logList); - $this->view->assign('quoteList', $quoteList); - $this->view->assign('review', $review); - $this->view->assign('settle', $settle); + // 7. 企业商家协同发包来源 + $merchantOrder = Db::name('merchant_order') + ->alias('mo') + ->join('merchant m', 'm.id = mo.merchant_id', 'LEFT') + ->where('mo.order_id', $row['id']) + ->field('mo.batch_no, mo.createtime as bind_time, m.id as merchant_id, m.name as merchant_name, m.contact as merchant_contact, m.mobile as merchant_mobile, m.settle_type') + ->find(); + + // 8. 发票开具关联信息 + $invoiceInfo = null; + if (!empty($row['invoice_id'])) { + $invoiceInfo = Db::name('invoice') + ->field('id,invoice_sn,title,type,amount,status,invoice_no,issue_time') + ->where('id', (int)$row['invoice_id']) + ->find(); + } + + // 9. 售后申诉争议记录 + $appealList = Db::name('appeal') + ->alias('a') + ->join('master m', 'm.id = a.master_id', 'LEFT') + ->where('a.order_id', $row['id']) + ->field('a.*, m.realname as master_name') + ->order('a.id desc') + ->select(); + + $this->view->assign([ + 'row' => $row, + 'goodsList' => $goodsList, + 'mediaList' => $mediaList, + 'logList' => $logList, + 'quoteList' => $quoteList, + 'review' => $review, + 'settle' => $settle, + 'merchantOrder' => $merchantOrder, + 'invoiceInfo' => $invoiceInfo, + 'appealList' => $appealList, + ]); return $this->view->fetch(); } diff --git a/server/application/admin/controller/Review.php b/server/application/admin/controller/Review.php index 44685e1..8bbe9fa 100644 --- a/server/application/admin/controller/Review.php +++ b/server/application/admin/controller/Review.php @@ -3,21 +3,27 @@ namespace app\admin\controller; use app\common\controller\Backend; +use think\Db; +use think\exception\PDOException; /** - * 评价 + * 服务评价治理 * - * @icon fa fa-circle-o + * 评价由完工业主真实提交,后台负责内容合规审核与违规屏蔽(联动晒单广场) + * + * @icon fa fa-comments-o */ class Review extends Backend { - /** * Review模型对象 * @var \app\admin\model\Review */ protected $model = null; + protected $relationSearch = true; + protected $searchFields = 'id,content,tags,order.order_no,user.nickname,master.realname'; + public function _initialize() { parent::_initialize(); @@ -25,18 +31,9 @@ class Review 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']); @@ -55,4 +52,229 @@ class Review extends Backend $result = ['total' => $list->total(), 'rows' => $list->items()]; return json($result); } + + /** + * 评价全景详情(关联订单、雇主、师傅及晒单状态) + */ + public function detail($ids = null) + { + $row = $this->model->get($ids); + if (!$row) { + $this->error(__('No Results were found')); + } + $this->assignReviewContext($row); + return $this->view->fetch(); + } + + /** + * 评价合规治理(保护业主原始评分与图文只读,仅变更展示状态与回复,并联动晒单表) + */ + public function edit($ids = null) + { + $row = $this->model->get($ids); + if (!$row) { + $this->error(__('No Results were found')); + } + if (false === $this->request->isPost()) { + $this->assignReviewContext($row); + return $this->view->fetch(); + } + + $params = $this->request->post('row/a', []); + $status = (string)($params['status'] ?? $row['status']); + $reply = trim((string)($params['reply'] ?? $row['reply'])); + + if (!in_array($status, ['normal', 'hidden'], true)) { + $this->error('评价状态参数不合法'); + } + + Db::startTrans(); + try { + $row->allowField(['status', 'reply'])->save([ + 'status' => $status, + 'reply' => mb_substr($reply, 0, 500), + ]); + + // 若屏蔽违规评价,同步下架该工单关联的业主晒单,防止首页继续曝光 + if ($status === 'hidden' && (int)$row['order_id'] > 0) { + Db::name('showcase')->where('order_id', (int)$row['order_id'])->update([ + 'status' => 'hidden', + 'updatetime' => time(), + ]); + } + + // 重算对应师傅综合评分 + if ((int)$row['master_id'] > 0) { + $this->recalcMasterScore((int)$row['master_id']); + } + + Db::commit(); + } catch (PDOException|\Exception $e) { + Db::rollback(); + $this->error($e->getMessage()); + } + $this->success(); + } + + /** + * 批量显隐同步联动晒单表并重算师傅评分 + */ + public function multi($ids = null) + { + if (false === $this->request->isPost()) { + $this->error(__('Invalid parameters')); + } + $ids = $ids ?: $this->request->post('ids'); + $params = (string)$this->request->post('params'); + if (empty($ids) || $params === '') { + $this->error(__('Parameter %s can not be empty', 'ids')); + } + parse_str($params, $values); + $status = $values['status'] ?? null; + if (!in_array($status, ['normal', 'hidden'], true)) { + $this->error('仅支持批量切换显示或隐藏状态'); + } + + $idArr = array_values(array_filter(array_map('intval', explode(',', (string)$ids)))); + if (!$idArr) { + $this->error(__('No rows were updated')); + } + + $affectedReviews = Db::name('review')->where('id', 'in', $idArr)->field('id,order_id,master_id')->select(); + + Db::startTrans(); + try { + Db::name('review')->where('id', 'in', $idArr)->update(['status' => $status]); + if ($status === 'hidden') { + $orderIds = array_values(array_filter(array_map('intval', array_column($affectedReviews, 'order_id')))); + if ($orderIds) { + Db::name('showcase')->where('order_id', 'in', $orderIds)->update([ + 'status' => 'hidden', + 'updatetime' => time(), + ]); + } + } + $masterIds = array_values(array_unique(array_filter(array_map('intval', array_column($affectedReviews, 'master_id'))))); + foreach ($masterIds as $mid) { + $this->recalcMasterScore($mid); + } + Db::commit(); + } catch (PDOException|\Exception $e) { + Db::rollback(); + $this->error($e->getMessage()); + } + $this->success(); + } + + /** + * 真实履约评价禁止后台手工伪造(刷单防御) + */ + public function add() + { + $this->error('服务评价仅支持完工业主真实提交,后台不可手动新增'); + } + + /** + * 删除违规评价(同步隐藏关联晒单并重算对应师傅综合评分) + */ + public function del($ids = null) + { + if (false === $this->request->isPost()) { + $this->error(__('Invalid parameters')); + } + $ids = $ids ?: $this->request->post('ids'); + $idArr = array_values(array_filter(array_map('intval', explode(',', (string)$ids)))); + if (!$idArr) { + $this->error(__('Parameter %s can not be empty', 'ids')); + } + $rows = Db::name('review')->where('id', 'in', $idArr)->field('id,order_id,master_id')->select(); + if (!$rows) { + $this->error(__('No Results were found')); + } + + Db::startTrans(); + try { + $orderIds = array_values(array_unique(array_filter(array_map('intval', array_column($rows, 'order_id'))))); + $masterIds = array_values(array_unique(array_filter(array_map('intval', array_column($rows, 'master_id'))))); + + Db::name('review')->where('id', 'in', $idArr)->delete(); + + if ($orderIds) { + Db::name('showcase')->where('order_id', 'in', $orderIds)->update([ + 'status' => 'hidden', + 'updatetime' => time(), + ]); + } + foreach ($masterIds as $mid) { + $this->recalcMasterScore($mid); + } + Db::commit(); + } catch (PDOException|\Exception $e) { + Db::rollback(); + $this->error($e->getMessage()); + } + $this->success(); + } + + /** + * 重算师傅有效公开评价的平均分并同步至师傅档案 + */ + protected function recalcMasterScore($masterId) + { + $masterId = (int)$masterId; + if ($masterId <= 0) { + return; + } + $avg = Db::name('review') + ->where('master_id', $masterId) + ->where('status', 'normal') + ->avg('score'); + $newScore = $avg !== null ? round((float)$avg, 2) : 5.00; + Db::name('master')->where('id', $masterId)->update([ + 'score' => $newScore, + 'updatetime' => time(), + ]); + } + + /** + * 解析标签字符串或 JSON 数组 + */ + protected function parseTags($raw) + { + $raw = trim((string)$raw); + if ($raw === '') { + return []; + } + if ($raw[0] === '[') { + $arr = json_decode($raw, true); + if (is_array($arr)) { + return array_values(array_filter(array_map('trim', $arr))); + } + } + return array_values(array_filter(array_map('trim', explode(',', $raw)))); + } + + /** + * 组装评价关联数据 + */ + protected function assignReviewContext($row) + { + $order = (int)$row['order_id'] > 0 ? Db::name('order')->where('id', (int)$row['order_id'])->find() : null; + $user = (int)$row['user_id'] > 0 ? Db::name('user')->where('id', (int)$row['user_id'])->field('id,nickname,username,mobile,avatar')->find() : null; + $master = (int)$row['master_id'] > 0 ? Db::name('master')->where('id', (int)$row['master_id'])->field('id,realname,mobile,score,order_count')->find() : null; + $showcase = (int)$row['order_id'] > 0 ? Db::name('showcase')->where('order_id', (int)$row['order_id'])->find() : null; + + $reviewTags = $this->parseTags($row['tags']); + $reviewImages = array_values(array_filter(array_map('trim', explode(',', (string)$row['images'])))); + + $this->view->assign([ + 'row' => $row, + 'order' => $order ?: [], + 'user' => $user ?: [], + 'master' => $master ?: [], + 'showcase' => $showcase ?: [], + 'reviewTags' => $reviewTags, + 'reviewImages' => $reviewImages, + ]); + } } diff --git a/server/application/admin/controller/Service.php b/server/application/admin/controller/Service.php index fc44d78..72f2b80 100644 --- a/server/application/admin/controller/Service.php +++ b/server/application/admin/controller/Service.php @@ -21,6 +21,7 @@ class Service extends Backend */ protected $model = null; protected $relationSearch = true; + protected $searchFields = 'id,name,desc,cat.name'; public function _initialize() { @@ -211,6 +212,45 @@ class Service extends Backend return $this->view->fetch(); } + /** + * 商品档案与服务定价全景详情 + */ + public function detail($ids = null) + { + $row = $this->model->get($ids); + if (!$row) { + $this->error(__('No Results were found')); + } + $catName = Db::name('service_cat')->where('id', $row->cat_id)->value('name') ?: '未归类'; + $serviceTypes = !empty($row->service_types) + ? (is_array($row->service_types) ? $row->service_types : (json_decode($row->service_types, true) ?: [])) + : []; + $addons = !empty($row->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(); + + $orderAgg = Db::name('order') + ->field('COUNT(*) AS total_cnt, SUM(CASE WHEN pay_status = 1 THEN pay_amount ELSE 0 END) AS total_gmv') + ->where('service_id', $row->id) + ->find(); + + $this->view->assign([ + 'row' => $row, + 'catName' => $catName, + 'serviceTypes' => $serviceTypes, + 'addons' => $addons, + 'specs' => $specs, + 'orderCount' => (int)($orderAgg['total_cnt'] ?? 0), + 'orderGmv' => number_format((float)($orderAgg['total_gmv'] ?? 0), 2, '.', ''), + ]); + return $this->view->fetch(); + } + /** * 格式化商品关联的主服务与增项服务 JSON,并自动提取默认主服务价格用于底层索引兼容 */ @@ -356,4 +396,36 @@ class Service extends Backend } $delQuery->delete(); } + + /** + * 删除保护:已产生历史业务订单的服务商品禁止物理删除,无关联订单删除时同步清理细分规格 + */ + public function del($ids = null) + { + if (false === $this->request->isPost()) { + $this->error(__('Invalid parameters')); + } + $ids = $ids ?: $this->request->post('ids'); + $idArr = array_values(array_filter(array_map('intval', explode(',', (string)$ids)))); + if (!$idArr) { + $this->error(__('Parameter %s can not be empty', 'ids')); + } + $ordCnt = (int)Db::name('order')->where('service_id', 'in', $idArr)->count(); + if ($ordCnt > 0) { + $this->error('所选商品已关联 ' . $ordCnt . ' 笔历史工单,禁止物理删除;如需下架请将状态设为隐藏'); + } + Db::startTrans(); + try { + Db::name('service_spec')->where('service_id', 'in', $idArr)->delete(); + $deleted = Db::name('service')->where('id', 'in', $idArr)->delete(); + Db::commit(); + } catch (Exception $e) { + Db::rollback(); + $this->error($e->getMessage()); + } + if ($deleted) { + $this->success(); + } + $this->error(__('No rows were deleted')); + } } diff --git a/server/application/admin/controller/Settle.php b/server/application/admin/controller/Settle.php index 207cf11..bf60abb 100644 --- a/server/application/admin/controller/Settle.php +++ b/server/application/admin/controller/Settle.php @@ -3,21 +3,27 @@ namespace app\admin\controller; use app\common\controller\Backend; +use think\Db; +use think\exception\PDOException; /** - * 结算单 + * 结算单管理 * - * @icon fa fa-circle-o + * 结算单由订单核销完工自动生成,后台负责对账审计及异常挂起单的人工结算归档 + * + * @icon fa fa-calculator */ class Settle extends Backend { - /** * Settle模型对象 * @var \app\admin\model\Settle */ protected $model = null; + protected $relationSearch = true; + protected $searchFields = 'id,order.order_no,master.realname,master.mobile'; + public function _initialize() { parent::_initialize(); @@ -25,18 +31,9 @@ class Settle 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']); @@ -55,4 +52,172 @@ class Settle extends Backend $result = ['total' => $list->total(), 'rows' => $list->items()]; return json($result); } + + /** + * 结算对账详情(关联订单费用明细、师傅钱包快照与入账流水) + */ + public function detail($ids = null) + { + $row = $this->model->get($ids); + if (!$row) { + $this->error(__('No Results were found')); + } + $this->assignSettleContext($row); + return $this->view->fetch(); + } + + /** + * 待结算单财务处理(严禁篡改结算金额,仅允许对 wait 状态执行线上入账或线下归档) + */ + public function edit($ids = null) + { + $row = $this->model->get($ids); + if (!$row) { + $this->error(__('No Results were found')); + } + if (false === $this->request->isPost()) { + $this->assignSettleContext($row); + return $this->view->fetch(); + } + + if ($row['status'] !== 'wait') { + $this->error('该结算单已归档完结,禁止重复结算或修改账务数据'); + } + + $targetStatus = (string)$this->request->post('row.status', ''); + if (!in_array($targetStatus, ['settled', 'offline'], true)) { + $this->error('请选择有效的结算归档方式(线上划拨入账 / 线下特殊结算)'); + } + + $masterId = (int)$row['master_id']; + $orderId = (int)$row['order_id']; + $settleAmount = round((float)$row['settle_amount'], 2); + $now = time(); + + Db::startTrans(); + try { + // 乐观锁防并发重复点击结算 + $affected = Db::name('settle') + ->where('id', (int)$row['id']) + ->where('status', 'wait') + ->update([ + 'status' => $targetStatus, + 'settle_time' => $now, + ]); + if (!$affected) { + throw new \Exception('结算单状态已变更,请刷新列表后重试'); + } + + $master = Db::name('master')->where('id', $masterId)->lock(true)->find(); + if (!$master) { + throw new \Exception('关联结算师傅不存在'); + } + $orderNo = $orderId > 0 ? (string)Db::name('order')->where('id', $orderId)->value('order_no') : ''; + + if ($targetStatus === 'settled') { + // 幂等校验:防止该订单此前已产生过 settle 流水 + $existsLog = $orderId > 0 && Db::name('wallet_log') + ->where('user_type', 'master') + ->where('user_id', $masterId) + ->where('order_id', $orderId) + ->where('type', 'settle') + ->find(); + if ($existsLog) { + throw new \Exception('检测到该订单已存在线上结算入账流水,请核对后再操作'); + } + + $newMoney = round((float)$master['money'] + $settleAmount, 2); + Db::name('master')->where('id', $masterId)->update([ + 'money' => $newMoney, + 'updatetime' => $now, + ]); + Db::name('wallet_log')->insert([ + 'user_type' => 'master', + 'user_id' => $masterId, + 'type' => 'settle', + 'amount' => $settleAmount, + 'balance' => $newMoney, + 'order_id' => $orderId, + 'remark' => '后台财务确认结算入账' . ($orderNo !== '' ? '(订单 ' . $orderNo . ')' : ''), + 'createtime' => $now, + ]); + $msgText = ($orderNo !== '' ? '订单 ' . $orderNo . ' ' : '') . '服务佣金 ¥' . number_format($settleAmount, 2, '.', '') . ' 已结算至您的平台钱包余额。'; + } else { + $msgText = ($orderNo !== '' ? '订单 ' . $orderNo . ' ' : '') . '服务佣金 ¥' . number_format($settleAmount, 2, '.', '') . ' 已由财务完成线下特殊结算归档。'; + } + + if ($orderId > 0) { + Db::name('order')->where('id', $orderId)->update([ + 'settle_status' => 1, + 'settle_amount' => $settleAmount, + 'updatetime' => $now, + ]); + } + + Db::name('message')->insert([ + 'receive_type' => 'master', + 'receive_id' => $masterId, + 'type' => 'order', + 'title' => '服务佣金结算通知', + 'content' => mb_substr($msgText, 0, 500), + 'order_id' => $orderId, + 'is_read' => 0, + 'createtime' => $now, + ]); + + 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('结算单涉及资金划拨,不支持批量修改状态,请逐单核对处理'); + } + + /** + * 组装结算单对账上下文 + */ + protected function assignSettleContext($row) + { + $order = (int)$row['order_id'] > 0 ? Db::name('order')->where('id', (int)$row['order_id'])->find() : null; + $master = (int)$row['master_id'] > 0 ? Db::name('master')->where('id', (int)$row['master_id'])->find() : null; + $walletLogs = ((int)$row['master_id'] > 0 && (int)$row['order_id'] > 0) + ? Db::name('wallet_log') + ->where('user_type', 'master') + ->where('user_id', (int)$row['master_id']) + ->where('order_id', (int)$row['order_id']) + ->order('id desc') + ->select() + : []; + + $this->view->assign([ + 'row' => $row, + 'order' => $order ?: [], + 'master' => $master ?: [], + 'walletLogs' => $walletLogs, + ]); + } } diff --git a/server/application/admin/controller/Showcase.php b/server/application/admin/controller/Showcase.php new file mode 100644 index 0000000..bfb0704 --- /dev/null +++ b/server/application/admin/controller/Showcase.php @@ -0,0 +1,169 @@ +model = new \app\admin\model\Showcase; + $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 + ->with(['order', 'user', 'service']) + ->where($where) + ->order($sort, $order) + ->paginate($limit); + + $items = $list->items(); + foreach ($items as &$item) { + if (!empty($item['images'])) { + $item['images'] = implode(',', $this->parseTags($item['images'])); + } + } + unset($item); + + return json(['total' => $list->total(), 'rows' => $items]); + } + + /** + * 晒单详情(关联源头工单与实拍组图) + */ + public function detail($ids = null) + { + $row = $this->model->with(['order', 'user', 'service'])->where('showcase.id', (int)$ids)->find(); + if (!$row) { + $this->error(__('No Results were found')); + } + $images = $this->parseTags($row['images']); + $tags = $this->parseTags($row['tags']); + + $this->view->assign([ + 'row' => $row, + 'images' => $images, + 'tags' => $tags, + ]); + return $this->view->fetch(); + } + + /** + * 晒单展示状态与封面治理 + */ + public function edit($ids = null) + { + $row = $this->model->with(['order', 'user', 'service'])->where('showcase.id', (int)$ids)->find(); + if (!$row) { + $this->error(__('No Results were found')); + } + $images = $this->parseTags($row['images']); + if (false === $this->request->isPost()) { + $this->view->assign([ + 'row' => $row, + 'images' => $images, + ]); + return $this->view->fetch(); + } + + $params = $this->request->post('row/a', []); + $status = in_array($params['status'] ?? '', ['normal', 'hidden'], true) ? $params['status'] : $row['status']; + $coverImage = trim((string)($params['cover_image'] ?? $row['cover_image'])); + + Db::name('showcase')->where('id', (int)$row['id'])->update([ + 'status' => $status, + 'cover_image' => mb_substr($coverImage, 0, 255), + 'updatetime' => time(), + ]); + $this->success(); + } + + /** + * 禁止后台伪造虚拟晒单 + */ + public function add() + { + $this->error('业主晒单仅由真实完工订单评价同步生成,后台不可手工伪造'); + } + + /** + * 删除违规晒单并同步复位源头工单晒单标记 + */ + public function del($ids = null) + { + if (false === $this->request->isPost()) { + $this->error(__('Invalid parameters')); + } + $ids = $ids ?: $this->request->post('ids'); + $idArr = array_values(array_filter(array_map('intval', explode(',', (string)$ids)))); + if (!$idArr) { + $this->error(__('Parameter %s can not be empty', 'ids')); + } + $rows = Db::name('showcase')->where('id', 'in', $idArr)->field('id,order_id')->select(); + if (!$rows) { + $this->error(__('No Results were found')); + } + Db::startTrans(); + try { + $orderIds = array_values(array_unique(array_filter(array_map('intval', array_column($rows, 'order_id'))))); + Db::name('showcase')->where('id', 'in', $idArr)->delete(); + if ($orderIds) { + Db::name('order')->where('id', 'in', $orderIds)->update([ + 'is_showcase' => 0, + 'showcase_time' => null, + ]); + } + Db::commit(); + } catch (\Exception $e) { + Db::rollback(); + $this->error($e->getMessage()); + } + $this->success(); + } + + protected function parseTags($raw) + { + $raw = trim((string)$raw); + if ($raw === '') { + return []; + } + if ($raw[0] === '[') { + $arr = json_decode($raw, true); + if (is_array($arr)) { + return array_values(array_filter(array_map('trim', $arr))); + } + } + return array_values(array_filter(array_map('trim', explode(',', $raw)))); + } +} diff --git a/server/application/admin/controller/SkillCategory.php b/server/application/admin/controller/SkillCategory.php index db60a29..a41a987 100644 --- a/server/application/admin/controller/SkillCategory.php +++ b/server/application/admin/controller/SkillCategory.php @@ -47,4 +47,54 @@ class SkillCategory 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')); + } + $oldName = (string)$row['name']; + if (false === $this->request->isPost()) { + $this->view->assign('row', $row); + return $this->view->fetch(); + } + $params = $this->request->post('row/a', []); + if (!$params) { + $this->error(__('Parameter %s can not be empty', '')); + } + $params = $this->preExcludeFields($params); + $result = $row->allowField(true)->save($params); + if ($result !== false) { + $newName = isset($params['name']) ? trim((string)$params['name']) : $oldName; + if ($newName !== '' && $newName !== $oldName) { + \think\Db::name('skill_dict')->where('category_id', (int)$row['id'])->update(['category' => $newName]); + } + $this->success(); + } + $this->error($row->getError() ?: __('No rows were updated')); + } + + /** + * 删除保护:分类下仍有关联技能时禁止物理删除 + */ + public function del($ids = null) + { + if (false === $this->request->isPost()) { + $this->error(__('Invalid parameters')); + } + $ids = $ids ?: $this->request->post('ids'); + $idArr = array_values(array_filter(array_map('intval', explode(',', (string)$ids)))); + if (!$idArr) { + $this->error(__('Parameter %s can not be empty', 'ids')); + } + $cnt = (int)\think\Db::name('skill_dict')->where('category_id', 'in', $idArr)->count(); + if ($cnt > 0) { + $this->error('所选分类下仍包含 ' . $cnt . ' 项技能标签,请先迁移或删除下属技能后再操作'); + } + return parent::del($ids); + } } diff --git a/server/application/admin/controller/SkillDict.php b/server/application/admin/controller/SkillDict.php index 9ed673c..1d0a9a6 100644 --- a/server/application/admin/controller/SkillDict.php +++ b/server/application/admin/controller/SkillDict.php @@ -17,26 +17,33 @@ class SkillDict extends Backend * @var \app\admin\model\SkillDict */ protected $model = null; - protected $searchFields = 'id,name,category'; + protected $relationSearch = true; + protected $searchFields = 'id,name,category,master.realname'; public function _initialize() { parent::_initialize(); $this->model = new \app\admin\model\SkillDict; - + // 从真实技能分类表中拉取分类列表 $categoryList = Db::name('skill_category') ->where('status', 'normal') ->order('weigh desc, id asc') ->column('name', 'id'); + $categorySearchList = []; + foreach ($categoryList as $catName) { + $categorySearchList[$catName] = $catName; + } + $this->view->assign("categoryList", $categoryList); + $this->assignconfig("categorySearchList", $categorySearchList); $this->view->assign("sourceList", $this->model->getSourceList()); $this->view->assign("statusList", $this->model->getStatusList()); } /** - * 查看技能列表 + * 查看技能列表(支持 category_id 跳转过滤,并统计持证师傅人数) */ public function index() { @@ -47,15 +54,40 @@ class SkillDict extends Backend if ($this->request->request('keyField')) { return $this->selectpage(); } + $catIdParam = (int)$this->request->get('category_id', 0); [$where, $sort, $order, $offset, $limit] = $this->buildparams(); - $list = $this->model + $query = $this->model ->with(['master']) - ->where($where) - ->order($sort, $order) - ->paginate($limit); + ->where($where); + if ($catIdParam > 0) { + $query->where('skill_dict.category_id', $catIdParam); + } + $list = $query->order($sort, $order)->paginate($limit); - $result = ['total' => $list->total(), 'rows' => $list->items()]; - return json($result); + $rows = $list->items(); + $names = []; + foreach ($rows as $r) { + if (!empty($r['name'])) { + $names[] = (string)$r['name']; + } + } + $masterCountMap = []; + if ($names) { + $stats = Db::name('master_skill') + ->where('skill_name', 'in', array_unique($names)) + ->field('skill_name, COUNT(DISTINCT master_id) AS cnt') + ->group('skill_name') + ->select(); + foreach ($stats as $st) { + $masterCountMap[$st['skill_name']] = (int)$st['cnt']; + } + } + foreach ($rows as &$item) { + $item['master_count'] = $masterCountMap[$item['name']] ?? 0; + } + unset($item); + + return json(['total' => $list->total(), 'rows' => $rows]); } /** @@ -81,6 +113,7 @@ class SkillDict extends Backend } $this->error(__('Parameter %s can not be empty', '')); } + $this->view->assign('defaultCategoryId', (int)$this->request->get('category_id', 0) ?: ''); return $this->view->fetch(); } @@ -114,4 +147,27 @@ class SkillDict extends Backend $this->view->assign("row", $row); return $this->view->fetch(); } + + /** + * 删除保护:已被师傅绑定的技能禁止物理删除 + */ + public function del($ids = null) + { + if (false === $this->request->isPost()) { + $this->error(__('Invalid parameters')); + } + $ids = $ids ?: $this->request->post('ids'); + $idArr = array_values(array_filter(array_map('intval', explode(',', (string)$ids)))); + if (!$idArr) { + $this->error(__('Parameter %s can not be empty', 'ids')); + } + $names = Db::name('skill_dict')->where('id', 'in', $idArr)->column('name'); + if ($names) { + $usedCnt = (int)Db::name('master_skill')->where('skill_name', 'in', $names)->count(); + if ($usedCnt > 0) { + $this->error('所选技能已被 ' . $usedCnt . ' 条师傅档案绑定,禁止物理删除,请改为「隐藏停用」'); + } + } + return parent::del($ids); + } } diff --git a/server/application/admin/controller/UserCoupon.php b/server/application/admin/controller/UserCoupon.php new file mode 100644 index 0000000..edf8e7a --- /dev/null +++ b/server/application/admin/controller/UserCoupon.php @@ -0,0 +1,105 @@ +model = new \app\admin\model\UserCoupon; + $this->view->assign('statusList', $this->model->getStatusList()); + } + + /** + * 领券记录列表(支持按状态 unused/used/expired 筛选及关联会员/优惠券/核销订单检索) + */ + 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(); + } + + // 自动将已超过优惠券截止时间的未使用领券记录同步为 expired + $now = time(); + $expiredCouponIds = Db::name('coupon') + ->where('end_time', '>', 0) + ->where('end_time', '<', $now) + ->column('id'); + if (!empty($expiredCouponIds)) { + Db::name('user_coupon') + ->where('status', 'unused') + ->where('coupon_id', 'in', $expiredCouponIds) + ->update(['status' => 'expired']); + } + + [$where, $sort, $order, $offset, $limit] = $this->buildparams(); + $list = $this->model + ->with(['user', 'coupon', 'order']) + ->where($where) + ->order($sort, $order) + ->paginate($limit); + + return json(['total' => $list->total(), 'rows' => $list->items()]); + } + + /** + * 禁止后台空造领券流水 + */ + public function add() + { + $this->error('用户领券记录由客户端领券中心真实发放生成,不支持后台手工伪造'); + } + + /** + * 删除未使用或已过期的无效领券记录(已核销关联订单的记录受账务保护禁止删除) + */ + public function del($ids = null) + { + if (false === $this->request->isPost()) { + $this->error(__('Invalid parameters')); + } + $ids = $ids ?: $this->request->post('ids'); + $idArr = array_values(array_filter(array_map('intval', explode(',', (string)$ids)))); + if (!$idArr) { + $this->error(__('Parameter %s can not be empty', 'ids')); + } + $usedCnt = Db::name('user_coupon')->where('id', 'in', $idArr)->where('status', 'used')->count(); + if ($usedCnt > 0) { + $this->error('所选记录中包含已核销抵扣订单的优惠券,为保障账务闭环禁止删除'); + } + Db::startTrans(); + try { + $deleted = Db::name('user_coupon')->where('id', 'in', $idArr)->delete(); + Db::commit(); + } catch (Exception $e) { + Db::rollback(); + $this->error($e->getMessage()); + } + if ($deleted) { + $this->success(); + } + $this->error(__('No rows were deleted')); + } +} diff --git a/server/application/admin/controller/WalletLog.php b/server/application/admin/controller/WalletLog.php new file mode 100644 index 0000000..153160c --- /dev/null +++ b/server/application/admin/controller/WalletLog.php @@ -0,0 +1,166 @@ +model = new \app\admin\model\WalletLog; + $this->view->assign('userTypeList', $this->model->getUserTypeList()); + $this->view->assign('typeList', $this->model->getTypeList()); + } + + /** + * 流水总账列表(批量解析师傅/用户姓名手机及关联订单号,并计算汇总指标) + */ + public function index() + { + $this->request->filter(['strip_tags', 'trim']); + if (false === $this->request->isAjax()) { + $totalIncome = round((float)Db::name('wallet_log')->where('amount', '>', 0)->sum('amount'), 2); + $totalOutflow = round(abs((float)Db::name('wallet_log')->where('amount', '<', 0)->sum('amount')), 2); + $this->view->assign([ + 'totalIncome' => $totalIncome, + 'totalOutflow' => $totalOutflow, + ]); + 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(); + $masterIds = []; + $userIds = []; + $orderIds = []; + foreach ($rows as $r) { + if ($r['user_type'] === 'master' && (int)$r['user_id'] > 0) { + $masterIds[] = (int)$r['user_id']; + } elseif ($r['user_type'] === 'user' && (int)$r['user_id'] > 0) { + $userIds[] = (int)$r['user_id']; + } + if ((int)$r['order_id'] > 0) { + $orderIds[] = (int)$r['order_id']; + } + } + + $masterMap = $masterIds + ? Db::name('master')->where('id', 'in', array_unique($masterIds))->column('id,realname,mobile', 'id') + : []; + $userMap = $userIds + ? Db::name('user')->where('id', 'in', array_unique($userIds))->column('id,nickname,username,mobile', 'id') + : []; + $orderMap = $orderIds + ? Db::name('order')->where('id', 'in', array_unique($orderIds))->column('id,order_no,service_name', 'id') + : []; + + foreach ($rows as &$item) { + $uid = (int)$item['user_id']; + if ($item['user_type'] === 'master') { + $m = $masterMap[$uid] ?? null; + $item['account_name'] = $m ? ($m['realname'] ?: ('师傅#' . $uid)) : ('师傅#' . $uid); + $item['account_mobile'] = $m['mobile'] ?? ''; + } else { + $u = $userMap[$uid] ?? null; + $item['account_name'] = $u ? ($u['nickname'] ?: ($u['username'] ?: ('用户#' . $uid))) : ('用户#' . $uid); + $item['account_mobile'] = $u['mobile'] ?? ''; + } + $oid = (int)$item['order_id']; + $ord = $oid > 0 ? ($orderMap[$oid] ?? null) : null; + $item['order_no'] = $ord['order_no'] ?? ''; + $item['service_name'] = $ord['service_name'] ?? ''; + } + unset($item); + + return json(['total' => $list->total(), 'rows' => $rows]); + } + + /** + * 资金流水溯源详情 + */ + public function detail($ids = null) + { + $row = $this->model->get($ids); + if (!$row) { + $this->error(__('No Results were found')); + } + $owner = []; + if ($row['user_type'] === 'master' && (int)$row['user_id'] > 0) { + $m = Db::name('master')->where('id', (int)$row['user_id'])->find(); + if ($m) { + $owner = [ + 'role' => '认证师傅', + 'name' => $m['realname'], + 'mobile' => $m['mobile'], + 'money' => $m['money'], + 'frozen' => $m['frozen_money'], + ]; + } + } elseif ((int)$row['user_id'] > 0) { + $u = Db::name('user')->where('id', (int)$row['user_id'])->find(); + if ($u) { + $owner = [ + 'role' => '平台注册用户', + 'name' => $u['nickname'] ?: $u['username'], + 'mobile' => $u['mobile'], + 'money' => $u['money'], + 'frozen' => '0.00', + ]; + } + } + + $order = (int)$row['order_id'] > 0 ? Db::name('order')->where('id', (int)$row['order_id'])->find() : null; + $settle = (int)$row['order_id'] > 0 ? Db::name('settle')->where('order_id', (int)$row['order_id'])->find() : null; + + $this->view->assign([ + 'row' => $row, + 'owner' => $owner, + 'order' => $order ?: [], + 'settle' => $settle ?: [], + ]); + return $this->view->fetch(); + } + + public function add() + { + $this->error('财务资金流水由业务交易自动记账,禁止手工新增'); + } + + public function edit($ids = null) + { + $this->error('财务资金流水属于不可篡改审计凭证,禁止编辑'); + } + + public function del($ids = null) + { + $this->error('财务资金流水属于不可篡改审计凭证,禁止删除'); + } + + public function multi($ids = null) + { + $this->error('财务资金流水禁止批量修改'); + } +} diff --git a/server/application/admin/controller/Withdraw.php b/server/application/admin/controller/Withdraw.php index 1bf4a02..13eaba3 100644 --- a/server/application/admin/controller/Withdraw.php +++ b/server/application/admin/controller/Withdraw.php @@ -23,6 +23,8 @@ class Withdraw extends Backend * @var \app\admin\model\Withdraw */ protected $model = null; + protected $relationSearch = true; + protected $searchFields = 'id,account,audit_msg,master.realname,master.mobile'; /** * 审核状态机:当前状态 => 允许流转到的目标状态 @@ -64,6 +66,44 @@ class Withdraw extends Backend return json($result); } + /** + * 提现审核与资金来源对账详情 + */ + public function detail($ids = null) + { + $row = $this->model->get($ids); + if (!$row) { + $this->error(__('No Results were found')); + } + $masterId = (int)$row['master_id']; + $master = $masterId > 0 ? Db::name('master')->where('id', $masterId)->find() : null; + $recentSettles = $masterId > 0 + ? Db::name('settle')->alias('s') + ->join('__ORDER__ o', 'o.id = s.order_id', 'LEFT') + ->where('s.master_id', $masterId) + ->field('s.*, o.order_no, o.service_name') + ->order('s.id desc') + ->limit(8) + ->select() + : []; + $walletLogs = $masterId > 0 + ? Db::name('wallet_log') + ->where('user_type', 'master') + ->where('user_id', $masterId) + ->order('id desc') + ->limit(10) + ->select() + : []; + + $this->view->assign([ + 'row' => $row, + 'master' => $master ?: [], + 'recentSettles' => $recentSettles, + 'walletLogs' => $walletLogs, + ]); + return $this->view->fetch(); + } + /** * 财务审核:申请信息只读,仅允许合法状态流转并做资金联动 * - 驳回:退回师傅余额并解冻,写入钱包流水,通知师傅 @@ -139,10 +179,10 @@ class Withdraw extends Backend 'remark' => '提现驳回,申请金额退回钱包余额' . ($auditMsg !== '' ? '(原因:' . $auditMsg . ')' : ''), 'createtime' => time(), ]); - $this->sendMasterMessage((int)$master['user_id'], '提现申请已驳回', '您的提现申请 ¥' . $amount . ' 未通过财务审核:' . ($auditMsg !== '' ? $auditMsg : '无') . ',申请金额已退回钱包余额'); + $this->sendMasterMessage($masterId, (int)$master['user_id'], '提现申请已驳回', '您的提现申请 ¥' . $amount . ' 未通过财务审核:' . ($auditMsg !== '' ? $auditMsg : '无') . ',申请金额已退回钱包余额'); } elseif ($status === 'pass') { // 审核通过:仅标记,资金保持冻结,等待财务打款 - $this->sendMasterMessage((int)$master['user_id'], '提现审核通过', '您的提现申请 ¥' . $amount . ' 已通过审核,财务将尽快安排打款到 ' . ($row['channel'] === 'bank' ? '银行卡' : '微信钱包')); + $this->sendMasterMessage($masterId, (int)$master['user_id'], '提现审核通过', '您的提现申请 ¥' . $amount . ' 已通过审核,财务将尽快安排打款到 ' . ($row['channel'] === 'bank' ? '银行卡' : '微信钱包')); } elseif ($status === 'done') { // 打款完成:冻结资金划出 Db::name('master')->where('id', $masterId)->update([ @@ -159,7 +199,7 @@ class Withdraw extends Backend 'remark' => '提现打款完成,冻结资金 ¥' . $amount . ' 划出(' . ($row['channel'] === 'bank' ? '银行储蓄卡' : '微信钱包') . ' ' . $row['account'] . ')', 'createtime' => time(), ]); - $this->sendMasterMessage((int)$master['user_id'], '提现打款完成', '您的提现 ¥' . $amount . ' 已打款成功,请留意到账通知'); + $this->sendMasterMessage($masterId, (int)$master['user_id'], '提现打款完成', '您的提现 ¥' . $amount . ' 已打款成功,请留意到账通知'); } Db::commit(); } catch (PDOException|\Exception $e) { @@ -194,25 +234,42 @@ class Withdraw extends Backend } /** - * 向师傅所属用户发送站内消息 - * @param int $userId 用户ID + * 向师傅端工作台及所属用户发送站内消息 + * @param int $masterId 师傅ID + * @param int $userId 用户ID * @param string $title 消息标题 * @param string $content 消息内容 */ - protected function sendMasterMessage($userId, $title, $content) + protected function sendMasterMessage($masterId, $userId, $title, $content) { - if (!$userId) { - return; + $now = time(); + $rows = []; + if ($masterId > 0) { + $rows[] = [ + 'receive_type' => 'master', + 'receive_id' => (int)$masterId, + 'type' => 'audit', + 'title' => mb_substr((string)$title, 0, 100), + 'content' => mb_substr((string)$content, 0, 500), + 'order_id' => 0, + 'is_read' => 0, + 'createtime' => $now, + ]; + } + if ($userId > 0) { + $rows[] = [ + 'receive_type' => 'user', + 'receive_id' => (int)$userId, + 'type' => 'system', + 'title' => mb_substr((string)$title, 0, 100), + 'content' => mb_substr((string)$content, 0, 500), + 'order_id' => 0, + 'is_read' => 0, + 'createtime' => $now, + ]; + } + if ($rows) { + Db::name('message')->insertAll($rows); } - 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/auth/Adminlog.php b/server/application/admin/controller/auth/Adminlog.php index 67ec10b..9453f21 100755 --- a/server/application/admin/controller/auth/Adminlog.php +++ b/server/application/admin/controller/auth/Adminlog.php @@ -18,6 +18,7 @@ class Adminlog extends Backend * @var \app\admin\model\AdminLog */ protected $model = null; + protected $searchFields = 'id,username,title,url,ip'; protected $childrenAdminIds = []; public function _initialize() diff --git a/server/application/admin/controller/service/Cat.php b/server/application/admin/controller/service/Cat.php index bb357fb..5fc47e1 100644 --- a/server/application/admin/controller/service/Cat.php +++ b/server/application/admin/controller/service/Cat.php @@ -17,6 +17,7 @@ class Cat extends Backend * @var \app\admin\model\service\Cat */ protected $model = null; + protected $searchFields = 'id,name'; public function _initialize() { @@ -59,4 +60,24 @@ class Cat extends Backend return json(['total' => $list->total(), 'rows' => $rows]); } + + /** + * 删除保护:类目下仍有关联商品时禁止物理删除 + */ + public function del($ids = null) + { + if (false === $this->request->isPost()) { + $this->error(__('Invalid parameters')); + } + $ids = $ids ?: $this->request->post('ids'); + $idArr = array_values(array_filter(array_map('intval', explode(',', (string)$ids)))); + if (!$idArr) { + $this->error(__('Parameter %s can not be empty', 'ids')); + } + $cnt = (int)Db::name('service')->where('cat_id', 'in', $idArr)->count(); + if ($cnt > 0) { + $this->error('所选类目下仍包含 ' . $cnt . ' 种服务商品,请先迁移或删除下属商品后再操作'); + } + return parent::del($ids); + } } diff --git a/server/application/admin/controller/service/Type.php b/server/application/admin/controller/service/Type.php index d0e9f39..d1c8f76 100644 --- a/server/application/admin/controller/service/Type.php +++ b/server/application/admin/controller/service/Type.php @@ -16,6 +16,7 @@ class Type extends Backend * @var \app\admin\model\service\Type */ protected $model = null; + protected $searchFields = 'id,name,code'; public function _initialize() { @@ -41,4 +42,31 @@ class Type extends Backend $result = ['total' => $list->total(), 'rows' => $list->items()]; return json($result); } + + /** + * 删除保护:已被服务商品定价矩阵引用的服务标识禁止物理删除 + */ + public function del($ids = null) + { + if (false === $this->request->isPost()) { + $this->error(__('Invalid parameters')); + } + $ids = $ids ?: $this->request->post('ids'); + $idArr = array_values(array_filter(array_map('intval', explode(',', (string)$ids)))); + if (!$idArr) { + $this->error(__('Parameter %s can not be empty', 'ids')); + } + $codes = \think\Db::name('service_type')->where('id', 'in', $idArr)->column('code'); + foreach ($codes as $code) { + if (!$code) continue; + $used = \think\Db::name('service') + ->where('service_types', 'like', '%"' . $code . '"%') + ->whereOr('addons', 'like', '%"' . $code . '"%') + ->count(); + if ($used > 0) { + $this->error('服务项【' . $code . '】已被 ' . $used . ' 种服务商品定价引用,禁止物理删除;如需停用请将状态设为隐藏'); + } + } + return parent::del($ids); + } } diff --git a/server/application/admin/controller/user/Group.php b/server/application/admin/controller/user/Group.php index b4c38c1..b71a21b 100755 --- a/server/application/admin/controller/user/Group.php +++ b/server/application/admin/controller/user/Group.php @@ -16,6 +16,7 @@ class Group extends Backend * @var \app\admin\model\UserGroup */ protected $model = null; + protected $searchFields = 'id,name'; public function _initialize() { diff --git a/server/application/admin/controller/user/User.php b/server/application/admin/controller/user/User.php index 6908ad6..0a55219 100755 --- a/server/application/admin/controller/user/User.php +++ b/server/application/admin/controller/user/User.php @@ -4,17 +4,17 @@ namespace app\admin\controller\user; use app\common\controller\Backend; use app\common\library\Auth; +use think\Db; /** - * 会员管理 + * 业主与会员管理 * * @icon fa fa-user */ class User extends Backend { - protected $relationSearch = true; - protected $searchFields = 'id,username,nickname'; + protected $searchFields = 'id,username,nickname,mobile'; /** * @var \app\admin\model\User @@ -28,14 +28,12 @@ class User extends Backend } /** - * 查看 + * 会员列表(批量聚合业务角色、订单数与实付消费额) */ public function index() { - //设置过滤方法 $this->request->filter(['strip_tags', 'trim']); if ($this->request->isAjax()) { - //如果发送的来源是Selectpage,则转发到Selectpage if ($this->request->request('keyField')) { return $this->selectpage(); } @@ -45,17 +43,160 @@ class User extends Backend ->where($where) ->order($sort, $order) ->paginate($limit); - foreach ($list as $k => $v) { - $v->avatar = $v->avatar ? cdnurl($v->avatar, true) : letter_avatar($v->nickname); - $v->hidden(['password', 'salt']); - } - $result = array("total" => $list->total(), "rows" => $list->items()); - return json($result); + $rows = $list->items(); + $uids = array_column($rows, 'id'); + + $masterMap = []; + $merchantMap = []; + $memberMap = []; + $orderStatMap = []; + $couponCntMap = []; + + if (!empty($uids)) { + $masters = Db::name('master')->field('id,user_id,realname,verify_status')->where('user_id', 'in', $uids)->select(); + foreach ($masters as $m) { + $masterMap[(int)$m['user_id']] = $m; + } + + $merchants = Db::name('merchant')->field('id,user_id,name,verify_status')->where('user_id', 'in', $uids)->select(); + foreach ($merchants as $mc) { + $merchantMap[(int)$mc['user_id']] = $mc; + } + + $members = Db::name('merchant_member')->alias('mm') + ->join('merchant m', 'm.id = mm.merchant_id', 'LEFT') + ->where('mm.user_id', 'in', $uids) + ->field('mm.user_id, m.name as merchant_name') + ->select(); + foreach ($members as $mb) { + $memberMap[(int)$mb['user_id']] = $mb['merchant_name']; + } + + $orderAggs = Db::name('order') + ->field('user_id, COUNT(*) AS cnt, SUM(CASE WHEN pay_status = 1 AND status <> 9 THEN pay_amount ELSE 0 END) AS spent') + ->where('user_id', 'in', $uids) + ->group('user_id') + ->select(); + foreach ($orderAggs as $oa) { + $orderStatMap[(int)$oa['user_id']] = [ + 'cnt' => (int)$oa['cnt'], + 'spent' => number_format((float)$oa['spent'], 2, '.', ''), + ]; + } + + $couponAggs = Db::name('user_coupon') + ->field('user_id, COUNT(*) AS cnt') + ->where('user_id', 'in', $uids) + ->where('status', 'unused') + ->group('user_id') + ->select(); + foreach ($couponAggs as $ca) { + $couponCntMap[(int)$ca['user_id']] = (int)$ca['cnt']; + } + } + + foreach ($rows as $v) { + $uid = (int)$v->id; + $v->avatar = $v->avatar ? (strpos($v->avatar, '/assets/') === 0 ? $v->avatar : cdnurl($v->avatar, true)) : letter_avatar($v->nickname); + $v->hidden(['password', 'salt']); + + $roles = []; + if (isset($masterMap[$uid]) && $masterMap[$uid]['verify_status'] === 'pass') { + $roles[] = ['type' => 'master', 'label' => '认证师傅 (' . $masterMap[$uid]['realname'] . ')']; + } + if (isset($merchantMap[$uid]) && $merchantMap[$uid]['verify_status'] === 'pass') { + $roles[] = ['type' => 'merchant', 'label' => '签约企业 (' . $merchantMap[$uid]['name'] . ')']; + } elseif (isset($memberMap[$uid]) && !empty($memberMap[$uid])) { + $roles[] = ['type' => 'member', 'label' => '企业子账号 (' . $memberMap[$uid] . ')']; + } + if (empty($roles)) { + $roles[] = ['type' => 'owner', 'label' => '个人业主']; + } + + $v['role_badges'] = $roles; + $v['order_count'] = $orderStatMap[$uid]['cnt'] ?? 0; + $v['total_spent'] = $orderStatMap[$uid]['spent'] ?? '0.00'; + $v['unused_coupons'] = $couponCntMap[$uid] ?? 0; + } + + return json(["total" => $list->total(), "rows" => $rows]); } return $this->view->fetch(); } + /** + * 会员业务全景详情看板 + */ + public function detail($ids = null) + { + $row = $this->model->find($ids); + if (!$row) { + $this->error(__('No Results were found')); + } + $row->avatar = $row->avatar ? (strpos($row->avatar, '/assets/') === 0 ? $row->avatar : cdnurl($row->avatar, true)) : letter_avatar($row->nickname); + $uid = (int)$row['id']; + + $masterProfile = Db::name('master')->where('user_id', $uid)->find(); + $merchantProfile = Db::name('merchant')->where('user_id', $uid)->find(); + if (!$merchantProfile) { + $memberBind = Db::name('merchant_member')->alias('mm') + ->join('merchant m', 'm.id = mm.merchant_id', 'LEFT') + ->where('mm.user_id', $uid) + ->field('m.*, mm.remark as member_remark') + ->find(); + if ($memberBind) { + $merchantProfile = $memberBind; + } + } + + $addressList = Db::name('address') + ->where('user_id', $uid) + ->order('is_default desc, id desc') + ->limit(6) + ->select(); + + $statusMap = (new \app\admin\model\Order)->getStatusList(); + $recentOrders = Db::name('order')->alias('o') + ->join('master m', 'm.id = o.master_id', 'LEFT') + ->where('o.user_id', $uid) + ->field('o.*, m.realname as master_name') + ->order('o.id desc') + ->limit(8) + ->select(); + foreach ($recentOrders as &$o) { + $o['status_label'] = $statusMap[(int)$o['status']] ?? ('状态' . $o['status']); + } + unset($o); + + $orderAgg = Db::name('order') + ->field('COUNT(*) AS total_cnt, SUM(CASE WHEN pay_status = 1 AND status <> 9 THEN pay_amount ELSE 0 END) AS total_spent') + ->where('user_id', $uid) + ->find(); + + $coupons = Db::name('user_coupon')->alias('uc') + ->join('coupon c', 'c.id = uc.coupon_id', 'LEFT') + ->join('order o', 'o.id = uc.order_id', 'LEFT') + ->where('uc.user_id', $uid) + ->field('uc.*, c.name as coupon_name, c.type as coupon_type, c.amount as coupon_amount, c.discount as coupon_discount, c.min_amount, o.order_no') + ->order('uc.id desc') + ->limit(8) + ->select(); + + $this->view->assign([ + 'row' => $row, + 'masterProfile' => $masterProfile, + 'merchantProfile' => $merchantProfile, + 'addressList' => $addressList, + 'recentOrders' => $recentOrders, + 'coupons' => $coupons, + 'orderCount' => (int)($orderAgg['total_cnt'] ?? 0), + 'totalSpent' => number_format((float)($orderAgg['total_spent'] ?? 0), 2, '.', ''), + ]); + + return $this->view->fetch(); + } + /** * 添加 */ @@ -63,29 +204,108 @@ class User extends Backend { if ($this->request->isPost()) { $this->token(); + $params = $this->request->post("row/a", []); + if (!$params) { + $this->error(__('Parameter %s can not be empty', '')); + } + $username = trim((string)($params['username'] ?? '')); + $nickname = trim((string)($params['nickname'] ?? '')); + $mobile = trim((string)($params['mobile'] ?? '')); + $email = trim((string)($params['email'] ?? '')); + $password = (string)($params['password'] ?? ''); + if ($username === '' || $nickname === '') { + $this->error('用户名与昵称不能为空'); + } + if (Db::name('user')->where('username', $username)->count() > 0) { + $this->error('该用户名已存在'); + } + if ($mobile !== '' && Db::name('user')->where('mobile', $mobile)->count() > 0) { + $this->error('该手机号已被注册'); + } + if ($email !== '' && Db::name('user')->where('email', $email)->count() > 0) { + $this->error('该邮箱已被绑定'); + } + if (strlen($password) < 6 || strlen($password) > 30) { + $this->error('登录密码长度须在6-30位之间'); + } + $salt = \fast\Random::alnum(); + $params['salt'] = $salt; + $params['password'] = Auth::instance()->getEncryptPassword($password, $salt); + $params['jointime'] = time(); + unset($params['money'], $params['score']); + $result = $this->model->allowField(true)->save($params); + if ($result !== false) { + $this->success(); + } + $this->error($this->model->getError() ?: __('No rows were inserted')); } - return parent::add(); + $this->view->assign('groupList', build_select('row[group_id]', \app\admin\model\UserGroup::column('id,name'), 1, ['class' => 'form-control selectpicker'])); + return $this->view->fetch(); } /** - * 编辑 + * 编辑(兼容微信授权注册无邮箱/无IP记录会员的状态修改与资料维护) */ public function edit($ids = null) { - if ($this->request->isPost()) { - $this->token(); - } $row = $this->model->get($ids); - $this->modelValidate = true; if (!$row) { $this->error(__('No Results were found')); } + if ($this->request->isPost()) { + $this->token(); + $params = $this->request->post("row/a", []); + if (!$params) { + $this->error(__('Parameter %s can not be empty', '')); + } + $uid = (int)$row['id']; + $username = isset($params['username']) ? trim((string)$params['username']) : (string)$row['username']; + $nickname = isset($params['nickname']) ? trim((string)$params['nickname']) : (string)$row['nickname']; + $mobile = isset($params['mobile']) ? trim((string)$params['mobile']) : (string)$row['mobile']; + $email = isset($params['email']) ? trim((string)$params['email']) : (string)$row['email']; + $password = (string)($params['password'] ?? ''); + + if ($username === '') { + $this->error('用户名不能为空'); + } + if (Db::name('user')->where('username', $username)->where('id', '<>', $uid)->count() > 0) { + $this->error('该用户名已被其他账号占用'); + } + if ($mobile !== '' && !preg_match('/^1[3-9]\d{9}$/', $mobile)) { + $this->error('手机号格式不正确'); + } + if ($mobile !== '' && Db::name('user')->where('mobile', $mobile)->where('id', '<>', $uid)->count() > 0) { + $this->error('该手机号已被其他账号绑定'); + } + if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) { + $this->error('电子邮箱格式不正确'); + } + if ($email !== '' && Db::name('user')->where('email', $email)->where('id', '<>', $uid)->count() > 0) { + $this->error('该邮箱已被其他账号绑定'); + } + if ($password !== '' && (strlen($password) < 6 || strlen($password) > 30)) { + $this->error('重置密码长度须在6-30位之间'); + } + + // 禁止通过会员资料编辑裸改资金余额与积分,保障账实一致 + unset($params['money'], $params['score'], $params['id']); + if ($nickname === '') { + $params['nickname'] = $row['nickname'] ?: $username; + } + + $result = $row->allowField(true)->save($params); + if ($result !== false) { + $this->success(); + } + $this->error($row->getError() ?: __('No rows were updated')); + } $this->view->assign('groupList', build_select('row[group_id]', \app\admin\model\UserGroup::column('id,name'), $row['group_id'], ['class' => 'form-control selectpicker'])); - return parent::edit($ids); + $this->view->assign('row', $row); + return $this->view->fetch(); } /** - * 删除 + * 删除(校验关联业务数据,防止产生孤儿工单与师傅档案) */ public function del($ids = "") { @@ -98,8 +318,13 @@ class User extends Backend if (!$row) { $this->error(__('No Results were found')); } - Auth::instance()->delete($row['id']); + $uid = (int)$row['id']; + if (Db::name('order')->where('user_id', $uid)->count() > 0 + || Db::name('master')->where('user_id', $uid)->count() > 0 + || Db::name('merchant')->where('user_id', $uid)->count() > 0) { + $this->error('该会员已关联历史订单、师傅或商家档案,禁止物理删除;如需停用请将状态设为隐藏'); + } + Auth::instance()->delete($uid); $this->success(); } - } diff --git a/server/application/admin/lang/zh-cn/invoice.php b/server/application/admin/lang/zh-cn/invoice.php index 04a1afb..a309826 100644 --- a/server/application/admin/lang/zh-cn/invoice.php +++ b/server/application/admin/lang/zh-cn/invoice.php @@ -1,20 +1,34 @@ '申请商户', - 'User_id' => '申请人/用户', - 'Amount' => '开票开具金额(元)', - 'Type' => '发票资质类型', - 'Title' => '开票抬头名称', - 'Tax_no' => '纳税人识别号(税号)', - 'Email' => '电子发票接收邮箱', - 'Remark' => '开票申请备注', - 'Status' => '开票处理状态', - 'Invoice_no' => '发票代码与号码', - 'Audit_msg' => '财务处理说明/驳回原因', - 'Ordinary' => '普通增值税电子发票', - 'Special' => '增值税专用发票', - 'Wait' => '待财务开具', - 'Issued' => '已开票寄送', - 'Reject' => '已驳回开票', + 'Invoice_sn' => '开票申请单号', + 'Merchant_id' => '申请商户', + 'User_id' => '申请人/用户', + 'Amount' => '开票金额(元)', + 'Type' => '发票类型', + 'Title_type' => '抬头类型', + 'Title' => '发票抬头名称', + 'Tax_no' => '纳税人识别号', + 'Bank_name' => '开户银行', + 'Bank_account' => '企业银行账号', + 'Reg_address' => '企业注册地址', + 'Reg_mobile' => '企业注册电话', + 'Email' => '接收邮箱', + 'Receive_mobile' => '通知手机号', + 'Remark' => '开票申请备注', + 'Order_count' => '关联订单数', + 'Status' => '处理状态', + 'Invoice_code' => '发票代码', + 'Invoice_no' => '发票号码', + 'Invoice_file' => '电子发票文件', + 'Audit_msg' => '财务处理说明/驳回原因', + 'Issue_time' => '开票完成时间', + 'Ordinary' => '增值税普通发票', + 'Special' => '增值税专用发票', + 'Personal' => '个人/非企业', + 'Enterprise' => '企业单位', + 'Wait' => '待财务开具', + 'Issued' => '已开票', + 'Reject' => '已驳回', + 'Cancel' => '用户已撤回', ]; diff --git a/server/application/admin/lang/zh-cn/invoice_title.php b/server/application/admin/lang/zh-cn/invoice_title.php new file mode 100644 index 0000000..2ba682e --- /dev/null +++ b/server/application/admin/lang/zh-cn/invoice_title.php @@ -0,0 +1,21 @@ + '所属用户', + 'Merchant_id' => '关联商户', + 'Title_type' => '抬头类型', + 'Default_invoice_type' => '偏好发票类型', + 'Title' => '发票抬头名称', + 'Tax_no' => '纳税人识别号', + 'Bank_name' => '开户银行', + 'Bank_account' => '企业银行账号', + 'Reg_address' => '企业注册地址', + 'Reg_mobile' => '企业注册电话', + 'Email' => '默认接收邮箱', + 'Receive_mobile' => '默认通知手机', + 'Is_default' => '默认抬头', + 'Personal' => '个人/非企业', + 'Enterprise' => '企业单位', + 'Ordinary' => '普通发票', + 'Special' => '增值税专用发票', +]; diff --git a/server/application/admin/lang/zh-cn/showcase.php b/server/application/admin/lang/zh-cn/showcase.php new file mode 100644 index 0000000..5f3ff4f --- /dev/null +++ b/server/application/admin/lang/zh-cn/showcase.php @@ -0,0 +1,22 @@ + 'ID', + 'Order_id' => '关联工单', + 'User_id' => '晒单业主', + 'User_name' => '展示昵称', + 'Cat_name' => '服务品类', + 'Service_id' => '关联服务', + 'Service_name' => '服务项目', + 'Region' => '所在区域', + 'Cover_image' => '瀑布流封面', + 'Images' => '实拍组图', + 'Score' => '评分', + 'Tags' => '评价标签', + 'Content' => '晒单内容', + 'Master_name' => '履约师傅', + 'Master_score' => '师傅评分', + 'Status' => '展示状态', + 'Createtime' => '发布时间', + 'Updatetime' => '更新时间', +]; diff --git a/server/application/admin/lang/zh-cn/user_coupon.php b/server/application/admin/lang/zh-cn/user_coupon.php new file mode 100644 index 0000000..7996597 --- /dev/null +++ b/server/application/admin/lang/zh-cn/user_coupon.php @@ -0,0 +1,14 @@ + '记录ID', + 'User_id' => '领用会员', + 'Coupon_id' => '所属优惠券', + 'Order_id' => '核销工单', + 'Status' => '使用状态', + 'Unused' => '未使用', + 'Used' => '已核销', + 'Expired' => '已过期', + 'Use_time' => '核销时间', + 'Createtime' => '领取时间', +]; diff --git a/server/application/admin/model/Invoice.php b/server/application/admin/model/Invoice.php index eb78ef5..252c8a7 100644 --- a/server/application/admin/model/Invoice.php +++ b/server/application/admin/model/Invoice.php @@ -4,17 +4,11 @@ namespace app\admin\model; use think\Model; - class Invoice extends Model { - - - - - // 表名 protected $name = 'invoice'; - + // 自动写入时间戳字段 protected $autoWriteTimestamp = 'integer'; @@ -26,21 +20,29 @@ class Invoice extends Model // 追加属性 protected $append = [ 'type_text', + 'title_type_text', 'status_text' ]; - - public function getTypeList() { return ['normal' => __('Ordinary'), 'special' => __('Special')]; } - public function getStatusList() + public function getTitleTypeList() { - return ['wait' => __('Wait'), 'issued' => __('Issued'), 'reject' => __('Reject')]; + return ['personal' => __('Personal'), 'enterprise' => __('Enterprise')]; } + public function getStatusList() + { + return [ + 'wait' => __('Wait'), + 'issued' => __('Issued'), + 'reject' => __('Reject'), + 'cancel' => __('Cancel'), + ]; + } public function getTypeTextAttr($value, $data) { @@ -49,6 +51,15 @@ class Invoice extends Model return $list[$value] ?? ''; } + public function getTitleTypeTextAttr($value, $data) + { + $value = $value ?: ($data['title_type'] ?? ''); + if ($value === '' && !empty($data['tax_no'])) { + $value = 'enterprise'; + } + $list = $this->getTitleTypeList(); + return $list[$value] ?? __('Personal'); + } public function getStatusTextAttr($value, $data) { @@ -57,10 +68,6 @@ class Invoice extends Model return $list[$value] ?? ''; } - - - - public function user() { return $this->belongsTo('User', 'user_id', 'id', [], 'LEFT')->setEagerlyType(0); diff --git a/server/application/admin/model/InvoiceTitle.php b/server/application/admin/model/InvoiceTitle.php new file mode 100644 index 0000000..cf818c3 --- /dev/null +++ b/server/application/admin/model/InvoiceTitle.php @@ -0,0 +1,41 @@ + '企业单位', + 'personal' => '个人/非企业', + ]; + } + + public function getDefaultInvoiceTypeList() + { + return [ + 'normal' => '增值税普通发票', + 'special' => '增值税专用发票', + ]; + } + + public function user() + { + return $this->belongsTo('User', 'user_id', 'id', [], 'LEFT')->setEagerlyType(0); + } + + public function merchant() + { + return $this->belongsTo('Merchant', 'merchant_id', 'id', [], 'LEFT')->setEagerlyType(0); + } +} diff --git a/server/application/admin/model/MasterSkill.php b/server/application/admin/model/MasterSkill.php index ba16e9d..da1df3f 100644 --- a/server/application/admin/model/MasterSkill.php +++ b/server/application/admin/model/MasterSkill.php @@ -36,11 +36,11 @@ class MasterSkill extends Model public function master() { - return $this->belongsTo('app\admin\model\Master', 'master_id', 'id')->setEagerlyType(0); + return $this->belongsTo('Master', 'master_id', 'id', [], 'LEFT')->setEagerlyType(0); } public function cat() { - return $this->belongsTo('app\admin\model\service\Cat', 'cat_id', 'id')->setEagerlyType(0); + return $this->belongsTo('app\admin\model\service\Cat', 'cat_id', 'id', [], 'LEFT')->setEagerlyType(0); } } diff --git a/server/application/admin/model/Showcase.php b/server/application/admin/model/Showcase.php new file mode 100644 index 0000000..0f22c14 --- /dev/null +++ b/server/application/admin/model/Showcase.php @@ -0,0 +1,49 @@ + '公开展示', + 'hidden' => '已屏蔽下架', + ]; + } + + public function getStatusTextAttr($value, $data) + { + $value = $value ?: ($data['status'] ?? ''); + $list = $this->getStatusList(); + return $list[$value] ?? ''; + } + + public function order() + { + return $this->belongsTo('Order', 'order_id', 'id', [], 'LEFT')->setEagerlyType(0); + } + + public function user() + { + return $this->belongsTo('User', 'user_id', 'id', [], 'LEFT')->setEagerlyType(0); + } + + public function service() + { + return $this->belongsTo('Service', 'service_id', 'id', [], 'LEFT')->setEagerlyType(0); + } +} diff --git a/server/application/admin/model/SkillDict.php b/server/application/admin/model/SkillDict.php index 412ffb9..e391576 100644 --- a/server/application/admin/model/SkillDict.php +++ b/server/application/admin/model/SkillDict.php @@ -68,6 +68,6 @@ class SkillDict extends Model public function master() { - return $this->belongsTo('app\admin\model\Master', 'master_id', 'id'); + return $this->belongsTo('Master', 'master_id', 'id', [], 'LEFT')->setEagerlyType(0); } } diff --git a/server/application/admin/model/UserCoupon.php b/server/application/admin/model/UserCoupon.php new file mode 100644 index 0000000..8dac4c0 --- /dev/null +++ b/server/application/admin/model/UserCoupon.php @@ -0,0 +1,62 @@ + '未使用', + 'used' => '已核销', + 'expired' => '已过期', + ]; + } + + public function getStatusTextAttr($value, $data) + { + $value = $value ?: ($data['status'] ?? ''); + $list = $this->getStatusList(); + return $list[$value] ?? ''; + } + + public function getUseTimeTextAttr($value, $data) + { + $value = $value ?: ($data['use_time'] ?? ''); + return $value && is_numeric($value) ? date('Y-m-d H:i:s', (int)$value) : ''; + } + + public function user() + { + return $this->belongsTo('User', 'user_id', 'id', [], 'LEFT')->setEagerlyType(0); + } + + public function coupon() + { + return $this->belongsTo('Coupon', 'coupon_id', 'id', [], 'LEFT')->setEagerlyType(0); + } + + public function order() + { + return $this->belongsTo('Order', 'order_id', 'id', [], 'LEFT')->setEagerlyType(0); + } +} diff --git a/server/application/admin/model/WalletLog.php b/server/application/admin/model/WalletLog.php new file mode 100644 index 0000000..079851b --- /dev/null +++ b/server/application/admin/model/WalletLog.php @@ -0,0 +1,35 @@ + '认证师傅', + 'user' => '业主/企业用户', + ]; + } + + public function getTypeList() + { + return [ + 'settle' => '佣金结算', + 'withdraw' => '余额提现', + 'refund' => '退回/退款', + 'recharge' => '账户充值', + 'pay' => '余额支付', + 'deduct' => '判责扣款', + ]; + } +} diff --git a/server/application/admin/view/appeal/add.html b/server/application/admin/view/appeal/add.html index 9943c89..ef257e7 100644 --- a/server/application/admin/view/appeal/add.html +++ b/server/application/admin/view/appeal/add.html @@ -11,7 +11,7 @@
{$order.order_no|htmlentities}该申诉未绑定具体业务订单。
+ {/if} +