diff --git a/db/schema_phase3.sql b/db/schema_phase3.sql new file mode 100644 index 0000000..87733f8 --- /dev/null +++ b/db/schema_phase3.sql @@ -0,0 +1,70 @@ +-- ============================================================================ +-- 优匠优服 · Phase 3 商家(企业账号) +-- 纯新增表,不改动既有表结构与数据 +-- ============================================================================ +SET NAMES utf8mb4; + +-- 商家入驻档案 +CREATE TABLE IF NOT EXISTS `fa_merchant` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `user_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '关联 fa_user.id', + `name` varchar(100) NOT NULL DEFAULT '' COMMENT '企业/商户名称', + `license_no` varchar(50) NOT NULL DEFAULT '' COMMENT '统一社会信用代码', + `license_image` varchar(255) NOT NULL DEFAULT '' COMMENT '营业执照图', + `legal_person` varchar(50) NOT NULL DEFAULT '' COMMENT '法人代表', + `contact` varchar(50) NOT NULL DEFAULT '' COMMENT '联系人', + `mobile` varchar(20) NOT NULL DEFAULT '' COMMENT '联系电话', + `province` varchar(50) NOT NULL DEFAULT '', + `city` varchar(50) NOT NULL DEFAULT '', + `district` varchar(50) NOT NULL DEFAULT '', + `address` varchar(255) NOT NULL DEFAULT '' COMMENT '经营地址', + `invoice_title` varchar(100) NOT NULL DEFAULT '' COMMENT '发票抬头', + `invoice_tax_no` varchar(50) NOT NULL DEFAULT '' COMMENT '纳税人识别号', + `invoice_bank` varchar(100) NOT NULL DEFAULT '' COMMENT '开户行', + `invoice_account` varchar(50) NOT NULL DEFAULT '' COMMENT '银行账号', + `settle_type` enum('prepay','monthly') NOT NULL DEFAULT 'prepay' COMMENT '预付/月结', + `verify_status` enum('pending','pass','reject') NOT NULL DEFAULT 'pending', + `audit_msg` varchar(255) NOT NULL DEFAULT '', + `order_count` int(11) NOT NULL DEFAULT '0', + `total_amount` decimal(12,2) NOT NULL DEFAULT '0.00', + `createtime` bigint(16) DEFAULT NULL, + `updatetime` bigint(16) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `user_id` (`user_id`), + KEY `verify_status` (`verify_status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商家档案'; + +-- 商家订单归属(批次对账用,不侵入 fa_order) +CREATE TABLE IF NOT EXISTS `fa_merchant_order` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `merchant_id` int(10) unsigned NOT NULL DEFAULT '0', + `order_id` int(10) unsigned NOT NULL DEFAULT '0', + `batch_no` varchar(32) NOT NULL DEFAULT '' COMMENT '批次号(批量下单)', + `createtime` bigint(16) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `order_id` (`order_id`), + KEY `merchant_id` (`merchant_id`), + KEY `batch_no` (`batch_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商家订单归属'; + +-- 开票申请 +CREATE TABLE IF NOT EXISTS `fa_invoice` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `merchant_id` int(10) unsigned NOT NULL DEFAULT '0', + `user_id` int(10) unsigned NOT NULL DEFAULT '0', + `amount` decimal(10,2) NOT NULL DEFAULT '0.00', + `type` enum('normal','special') NOT NULL DEFAULT 'normal' COMMENT '普票/专票', + `title` varchar(100) NOT NULL DEFAULT '', + `tax_no` varchar(50) NOT NULL DEFAULT '', + `email` varchar(100) NOT NULL DEFAULT '', + `remark` varchar(255) NOT NULL DEFAULT '', + `status` enum('wait','issued','reject') NOT NULL DEFAULT 'wait', + `invoice_no` varchar(50) NOT NULL DEFAULT '', + `createtime` bigint(16) DEFAULT NULL, + `updatetime` bigint(16) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `merchant_id` (`merchant_id`), + KEY `status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='开票申请'; + +-- 回滚:DROP TABLE IF EXISTS `fa_invoice`,`fa_merchant_order`,`fa_merchant`; diff --git a/manifest.json b/manifest.json index 84c7fbc..fa99eb3 100644 --- a/manifest.json +++ b/manifest.json @@ -50,7 +50,7 @@ "quickapp" : {}, /* 小程序特有相关 */ "mp-weixin" : { - "appid" : "", + "appid" : "wx40ca4b02d4d1cb58", "setting" : { "urlCheck" : false }, diff --git a/pages.json b/pages.json index e59a1be..ee7f627 100644 --- a/pages.json +++ b/pages.json @@ -66,6 +66,22 @@ "path": "pages/mine/mine", "style": { "navigationStyle": "custom", "backgroundColor": "#F5F6F8" } }, + { + "path": "pages/merchant/apply", + "style": { "navigationBarTitleText": "商家入驻" } + }, + { + "path": "pages/merchant/workbench", + "style": { "navigationBarTitleText": "商家工作台" } + }, + { + "path": "pages/merchant/batch", + "style": { "navigationBarTitleText": "批量下单" } + }, + { + "path": "pages/merchant/invoice", + "style": { "navigationBarTitleText": "开具发票" } + }, { "path": "pages/master/orders", "style": { diff --git a/pages/login/login.vue b/pages/login/login.vue index 6d29112..b5ce727 100644 --- a/pages/login/login.vue +++ b/pages/login/login.vue @@ -10,13 +10,9 @@ - - 我是业主 - 下单找师傅 - - - 我是师傅 - 接单赚收入 + + {{ r.name }} + {{ r.desc }} @@ -25,6 +21,17 @@ {{ loading ? '微信授权中...' : '微信一键登录' }} + + + {{ showPhone ? '收起手机号登录' : '微信授权失败?改用手机号登录' }} + + + + + 手机号登录 / 注册 + + + 授权仅用于账号识别,不会获取你的其他信息 登录即代表同意《用户服务协议》与《隐私政策》 @@ -41,6 +48,15 @@ const role = ref('user'); const loading = ref(false); const showBack = ref(false); + const showPhone = ref(false); + const mobile = ref(''); + const code = ref(''); + + const roles = [ + { key: 'user', name: '我是业主', desc: '下单找师傅' }, + { key: 'master', name: '我是师傅', desc: '接单赚收入' }, + { key: 'merchant', name: '我是商家', desc: '企业批量下单' } + ]; function toast(t) { uni.showToast({ title: t, icon: 'none' }); } @@ -83,11 +99,39 @@ }); } + function phoneLogin() { + if (loading.value) return; + if (!/^1\d{10}$/.test(mobile.value)) { toast('请输入正确的手机号'); return; } + if (!code.value) { toast('请输入验证码'); return; } + loading.value = true; + api.login({ mobile: mobile.value, code: code.value }).then((data) => { + loading.value = false; + setToken(data.token); + setUser(data.userinfo); + setRole(role.value); + toast('登录成功'); + setTimeout(() => routeAfterLogin(), 500); + }).catch((e) => { + loading.value = false; + toast(e.message || '登录失败'); + }); + } + function routeAfterLogin() { if (role.value === 'user') { uni.reLaunch({ url: '/YJYF/YJYF' }); return; } + if (role.value === 'merchant') { + api.merchantInfo().then((res) => { + if (!res.applied || res.merchant.verify_status !== 'pass') { + uni.redirectTo({ url: '/pages/merchant/apply' }); + } else { + uni.redirectTo({ url: '/pages/merchant/workbench' }); + } + }).catch(() => uni.redirectTo({ url: '/pages/merchant/apply' })); + return; + } api.masterInfo().then((res) => { if (!res.applied || res.master.verify_status !== 'pass') { uni.redirectTo({ url: '/pages/master/apply' }); @@ -112,12 +156,19 @@ .brand { position: relative; display: block; font-size: 52rpx; font-weight: 700; color: #fff; letter-spacing: 6rpx; text-shadow: 0 3rpx 12rpx rgba(180,80,10,.3); } .slogan { position: relative; display: block; margin-top: 14rpx; font-size: 26rpx; color: rgba(255,255,255,.88); letter-spacing: 2rpx; } .card { position: relative; margin: -140rpx 40rpx 0; padding: 40rpx 34rpx 34rpx; background: #fff; border-radius: 24rpx; box-shadow: 0 16rpx 40rpx rgba(226,120,26,.16); } - .role { display: flex; margin-bottom: 36rpx; } - .role-item { flex: 1; padding: 22rpx 0; border-radius: 16rpx; background: #F7F7F7; display: flex; flex-direction: column; align-items: center; border: 2rpx solid transparent; } + .role { display: flex; margin-bottom: 34rpx; } + .role-item { flex: 1; margin-right: 12rpx; padding: 18rpx 0; border-radius: 16rpx; background: #F7F7F7; display: flex; flex-direction: column; align-items: center; border: 2rpx solid transparent; } + .role-item:last-child { margin-right: 0; } .role-item.on { background: linear-gradient(135deg, #FFF8EE 0%, #FFE9D2 100%); border-color: #F9A65B; } - .role-name { font-size: 30rpx; font-weight: 600; color: #333; } + .role-name { font-size: 26rpx; font-weight: 600; color: #333; } .role-item.on .role-name { color: #C4580A; } - .role-desc { margin-top: 6rpx; font-size: 22rpx; color: #9A9A9A; } + .role-desc { margin-top: 4rpx; font-size: 19rpx; color: #9A9A9A; } + .fallback { margin-top: 20rpx; } + .fb-toggle { text-align: center; padding: 12rpx 0; } + .fb-toggle-t { font-size: 22rpx; color: #9A9A9A; text-decoration: underline; } + .fb-body { margin-top: 10rpx; } + .fb-input { height: 88rpx; margin-bottom: 14rpx; padding: 0 24rpx; background: #F7F7F7; border-radius: 14rpx; font-size: 28rpx; color: #222; } + .fb-btn { height: 84rpx; display: flex; align-items: center; justify-content: center; border-radius: 42rpx; background: #FFF3E6; color: #E36B0C; font-size: 28rpx; font-weight: 600; } .wx-btn { height: 96rpx; display: flex; align-items: center; justify-content: center; border-radius: 48rpx; background: linear-gradient(135deg, #4BC86A 0%, #2FA84F 100%); box-shadow: 0 10rpx 24rpx rgba(47,168,79,.28); } .wx-btn.disabled { opacity: .7; } .wx-icon { width: 44rpx; height: 44rpx; border-radius: 50%; background: #fff; display: flex; align-items: center; justify-content: center; margin-right: 14rpx; } diff --git a/pages/merchant/apply.vue b/pages/merchant/apply.vue new file mode 100644 index 0000000..8d4a5ec --- /dev/null +++ b/pages/merchant/apply.vue @@ -0,0 +1,139 @@ + + + + + + diff --git a/pages/merchant/batch.vue b/pages/merchant/batch.vue new file mode 100644 index 0000000..ff28265 --- /dev/null +++ b/pages/merchant/batch.vue @@ -0,0 +1,189 @@ + + + + + + diff --git a/pages/merchant/invoice.vue b/pages/merchant/invoice.vue new file mode 100644 index 0000000..26198ec --- /dev/null +++ b/pages/merchant/invoice.vue @@ -0,0 +1,117 @@ + + + + + diff --git a/pages/merchant/workbench.vue b/pages/merchant/workbench.vue new file mode 100644 index 0000000..d2ac3da --- /dev/null +++ b/pages/merchant/workbench.vue @@ -0,0 +1,145 @@ + + + + + + diff --git a/pages/mine/mine.vue b/pages/mine/mine.vue index fa1d21d..73eeeeb 100644 --- a/pages/mine/mine.vue +++ b/pages/mine/mine.vue @@ -39,6 +39,14 @@ {{ masterCard.btn }} + + + {{ merchantCard.title }} + {{ merchantCard.desc }} + + {{ merchantCard.btn }} + + 我的订单› @@ -70,11 +78,16 @@ const user = ref(null); const master = ref(null); + const merchant = ref(null); const unread = ref(0); const coupon = ref(0); const avatarText = computed(() => (user.value && user.value.nickname ? user.value.nickname.slice(0, 1) : '我')); - const roleText = computed(() => (master.value && master.value.verify_status === 'pass' ? '认证师傅' : '业主')); + const roleText = computed(() => { + if (master.value && master.value.verify_status === 'pass') return '认证师傅'; + if (merchant.value && merchant.value.verify_status === 'pass') return '认证商家'; + return '个人用户'; + }); const masterCard = computed(() => { const m = master.value; @@ -90,6 +103,20 @@ return { title: '切换为师傅身份', desc: '进入师傅工作台接单', btn: '切换师傅端', action: switchToMaster }; }); + const merchantCard = computed(() => { + const m = merchant.value; + if (!m) { + return { title: '成为优匠优服商家', desc: '企业认证,支持批量下单 / 对账 / 开票', btn: '商家入驻', action: () => go('/pages/merchant/apply') }; + } + if (m.verify_status === 'pending') { + return { title: '商家资质审核中', desc: '平台将在 1-2 个工作日内完成审核', btn: '查看进度', action: () => go('/pages/merchant/apply') }; + } + if (m.verify_status === 'reject') { + return { title: '商家资质未通过', desc: m.audit_msg || '请补充资料后重新提交', btn: '重新提交', action: () => go('/pages/merchant/apply') }; + } + return { title: m.name || '我的企业账号', desc: '进入商家工作台:批量下单 / 账单 / 开票', btn: '切换商家端', action: switchToMerchant }; + }); + function toast(t) { uni.showToast({ title: t, icon: 'none' }); } function go(url) { uni.navigateTo({ url: url }); } function callService() { uni.makePhoneCall({ phoneNumber: '4000000000' }); } @@ -100,12 +127,22 @@ const res = await api.profile(); user.value = res; master.value = res.master; + merchant.value = res.merchant; unread.value = res.unread_count || 0; coupon.value = res.coupon_count || 0; setUser(res); } catch (e) {} } + async function switchToMerchant() { + try { + const res = await api.switchRole({ role: 'merchant' }); + setRole('merchant'); + uni.showToast({ title: '已切换为商家身份' }); + setTimeout(() => uni.reLaunch({ url: res.redirect }), 500); + } catch (e) {} + } + async function switchToMaster() { try { const res = await api.switchRole({ role: 'master' }); diff --git a/server/application/api/controller/Auth.php b/server/application/api/controller/Auth.php index 477319d..576aa98 100644 --- a/server/application/api/controller/Auth.php +++ b/server/application/api/controller/Auth.php @@ -87,6 +87,19 @@ class Auth extends Base ]; } + $merchant = Db::name('merchant')->where('user_id', $uid)->find(); + $merchantInfo = null; + if ($merchant) { + $merchantInfo = [ + 'id' => (int)$merchant['id'], + 'name' => $merchant['name'], + 'verify_status' => $merchant['verify_status'], + 'audit_msg' => $merchant['audit_msg'], + 'settle_type' => $merchant['settle_type'], + 'can_order' => $merchant['verify_status'] === 'pass', + ]; + } + return [ 'id' => (int)$user['id'], 'nickname' => $user['nickname'] ?: ('用户' . substr((string)$user['mobile'], -4)), @@ -97,6 +110,8 @@ class Auth extends Base 'unread_count' => (int)$unread, 'is_master' => $master ? true : false, 'master' => $masterInfo, + 'is_merchant' => $merchant ? true : false, + 'merchant' => $merchantInfo, ]; } @@ -141,9 +156,10 @@ class Auth extends Base public function switchRole() { $role = $this->request->post('role', 'user'); - if (!in_array($role, ['user', 'master'], true)) { + if (!in_array($role, ['user', 'master', 'merchant'], true)) { $this->error('角色参数错误'); } + if ($role === 'master') { $master = $this->masterInfo(); if (!$master) { @@ -156,9 +172,27 @@ class Auth extends Base $this->error('资质未通过:' . $master['audit_msg'], ['verify_status' => 'reject'], 2003); } } - $this->success('切换成功', [ - 'role' => $role, - 'redirect' => $role === 'master' ? '/pages/master/workbench' : '/YJYF/YJYF', - ]); + + if ($role === 'merchant') { + $merchant = Db::name('merchant')->where('user_id', $this->uid())->find(); + if (!$merchant) { + $this->error('请先完成商家入驻', ['need_apply' => 1], 2001); + } + if ($merchant['verify_status'] === 'pending') { + $this->error('商家资质审核中,请耐心等待', ['verify_status' => 'pending'], 2002); + } + if ($merchant['verify_status'] === 'reject') { + $this->error('资质未通过:' . $merchant['audit_msg'], ['verify_status' => 'reject'], 2003); + } + } + + $redirect = '/YJYF/YJYF'; + if ($role === 'master') { + $redirect = '/pages/master/workbench'; + } elseif ($role === 'merchant') { + $redirect = '/pages/merchant/workbench'; + } + + $this->success('切换成功', ['role' => $role, 'redirect' => $redirect]); } } diff --git a/server/application/api/controller/Merchant.php b/server/application/api/controller/Merchant.php new file mode 100644 index 0000000..485b9de --- /dev/null +++ b/server/application/api/controller/Merchant.php @@ -0,0 +1,292 @@ + trim((string)$this->request->post('name')), + 'license_no' => trim((string)$this->request->post('license_no')), + 'license_image' => (string)$this->request->post('license_image'), + 'legal_person' => trim((string)$this->request->post('legal_person')), + 'contact' => trim((string)$this->request->post('contact')), + 'mobile' => trim((string)$this->request->post('mobile')), + 'province' => trim((string)$this->request->post('province')), + 'city' => trim((string)$this->request->post('city')), + 'district' => trim((string)$this->request->post('district')), + 'address' => trim((string)$this->request->post('address')), + 'invoice_title' => trim((string)$this->request->post('invoice_title')), + 'invoice_tax_no' => trim((string)$this->request->post('invoice_tax_no')), + 'invoice_bank' => trim((string)$this->request->post('invoice_bank')), + 'invoice_account' => trim((string)$this->request->post('invoice_account')), + 'settle_type' => $this->request->post('settle_type', 'prepay'), + ]; + + if ($data['name'] === '' || $data['license_no'] === '' || $data['contact'] === '' || $data['mobile'] === '') { + $this->error('请填写企业名称、统一社会信用代码、联系人和电话'); + } + if (!preg_match('/^1\d{10}$/', $data['mobile'])) { + $this->error('联系电话格式不正确'); + } + if (!in_array($data['settle_type'], ['prepay', 'monthly'], true)) { + $data['settle_type'] = 'prepay'; + } + + $exist = Db::name('merchant')->where('user_id', $this->uid())->find(); + if ($exist && $exist['verify_status'] === 'pass') { + $this->error('您已是认证商家,无需重复入驻'); + } + + $save = array_merge($data, [ + 'verify_status' => 'pending', + 'audit_msg' => '', + 'updatetime' => time(), + ]); + + if ($exist) { + Db::name('merchant')->where('id', $exist['id'])->update($save); + $id = (int)$exist['id']; + } else { + $save['user_id'] = $this->uid(); + $save['createtime'] = time(); + $id = Db::name('merchant')->insertGetId($save); + } + + $this->success('提交成功,请等待审核', ['merchant_id' => $id]); + } + + /** + * 商家档案与经营数据 + */ + public function info() + { + $merchant = $this->merchantInfo(); + if (!$merchant) { + $this->success('未入驻', ['applied' => false]); + return; + } + + $uid = $this->uid(); + $orderCount = (int)Db::name('order')->where('user_id', $uid)->where('pay_status', 1)->count(); + $totalAmount = (float)Db::name('order')->where('user_id', $uid)->where('pay_status', 1)->sum('pay_amount'); + $invoiced = (float)Db::name('invoice')->where('merchant_id', $merchant['id'])->where('status', 'issued')->sum('amount'); + + $this->success('', [ + 'applied' => true, + 'merchant' => [ + 'id' => (int)$merchant['id'], + 'name' => $merchant['name'], + 'license_no' => $merchant['license_no'], + 'legal_person' => $merchant['legal_person'], + 'contact' => $merchant['contact'], + 'mobile' => $merchant['mobile'], + 'address' => $merchant['province'] . $merchant['city'] . $merchant['district'] . $merchant['address'], + 'invoice_title' => $merchant['invoice_title'], + 'invoice_tax_no' => $merchant['invoice_tax_no'], + 'settle_type' => $merchant['settle_type'], + 'verify_status' => $merchant['verify_status'], + 'audit_msg' => $merchant['audit_msg'], + 'order_count' => $orderCount, + 'total_amount' => round($totalAmount, 2), + 'invoiced_amount' => round($invoiced, 2), + 'invoiceable' => round(max(0, $totalAmount - $invoiced), 2), + ], + ]); + } + + /** + * 维护资料(联系人 / 地址 / 开票信息) + * @ApiMethod (POST) + */ + public function update() + { + $merchant = $this->requireMerchant(); + $fields = ['contact', 'mobile', 'province', 'city', 'district', 'address', + 'invoice_title', 'invoice_tax_no', 'invoice_bank', 'invoice_account']; + $data = ['updatetime' => time()]; + foreach ($fields as $f) { + $v = $this->request->post($f, null); + if ($v !== null) { + $data[$f] = trim((string)$v); + } + } + if (isset($data['mobile']) && $data['mobile'] !== '' && !preg_match('/^1\d{10}$/', $data['mobile'])) { + $this->error('联系电话格式不正确'); + } + Db::name('merchant')->where('id', $merchant['id'])->update($data); + $this->success('已保存'); + } + + /** + * 账单中心:按月汇总 + 近期订单 + */ + public function bills() + { + $this->requireMerchant(); + $uid = $this->uid(); + + $months = Db::name('order') + ->field("FROM_UNIXTIME(createtime,'%Y-%m') as month, count(*) as cnt, sum(pay_amount) as amount") + ->where('user_id', $uid)->where('pay_status', 1) + ->group('month')->order('month desc')->limit(12)->select(); + foreach ($months as &$m) { + $m['amount'] = round((float)$m['amount'], 2); + $m['cnt'] = (int)$m['cnt']; + } + unset($m); + + $orders = Db::name('order')->field('id,order_no,service_name,pay_amount,status,createtime') + ->where('user_id', $uid)->order('id desc')->limit(20)->select(); + foreach ($orders as &$o) { + $o['pay_amount'] = (float)$o['pay_amount']; + $o['status_text'] = \app\common\library\OrderStatus::label($o['status']); + $o['time_text'] = date('Y-m-d H:i', (int)$o['createtime']); + } + unset($o); + + $this->success('', ['months' => $months, 'orders' => $orders]); + } + + /** + * 开票记录 + */ + public function invoices() + { + $merchant = $this->requireMerchant(); + $list = Db::name('invoice')->where('merchant_id', $merchant['id'])->order('id desc')->limit(30)->select(); + $statusText = ['wait' => '开票中', 'issued' => '已开票', 'reject' => '已驳回']; + foreach ($list as &$row) { + $row['amount'] = (float)$row['amount']; + $row['status_text'] = isset($statusText[$row['status']]) ? $statusText[$row['status']] : $row['status']; + $row['type_text'] = $row['type'] === 'special' ? '专用发票' : '普通发票'; + $row['time_text'] = date('Y-m-d H:i', (int)$row['createtime']); + } + unset($row); + $this->success('', ['list' => $list]); + } + + /** + * 申请开票 + * @ApiMethod (POST) + */ + public function invoice() + { + $merchant = $this->requireMerchant(); + $amount = round((float)$this->request->post('amount'), 2); + $type = $this->request->post('type', 'normal'); + $email = trim((string)$this->request->post('email')); + + if ($amount <= 0) { + $this->error('请填写开票金额'); + } + if (!in_array($type, ['normal', 'special'], true)) { + $this->error('发票类型错误'); + } + + $paid = (float)Db::name('order')->where('user_id', $this->uid())->where('pay_status', 1)->sum('pay_amount'); + $used = (float)Db::name('invoice')->where('merchant_id', $merchant['id'])->where('status', 'in', ['wait', 'issued'])->sum('amount'); + $available = round(max(0, $paid - $used), 2); + if ($amount > $available) { + $this->error('可开票金额不足,当前可开 ¥' . $available); + } + + Db::name('invoice')->insert([ + 'merchant_id' => (int)$merchant['id'], + 'user_id' => $this->uid(), + 'amount' => $amount, + 'type' => $type, + 'title' => $merchant['invoice_title'] ?: $merchant['name'], + 'tax_no' => $merchant['invoice_tax_no'], + 'email' => $email, + 'status' => 'wait', + 'createtime' => time(), + 'updatetime' => time(), + ]); + + $this->success('开票申请已提交,3 个工作日内开具'); + } + + /** + * 批量下单后绑定批次(订单本身走已验收的 /order/create) + * @ApiMethod (POST) + */ + public function bindBatch() + { + $merchant = $this->requireMerchant(); + // 兼容三种入参:数组 / 逗号串 / 混合 + $raw = $this->request->post('order_ids'); + $orderIds = []; + foreach ((array)$raw as $v) { + foreach (explode(',', (string)$v) as $one) { + $one = (int)$one; + if ($one > 0) { + $orderIds[] = $one; + } + } + } + $orderIds = array_values(array_unique($orderIds)); + $batchNo = trim((string)$this->request->post('batch_no', '')); + if (empty($orderIds)) { + $this->error('缺少订单'); + } + if ($batchNo === '') { + $batchNo = date('YmdHis') . mt_rand(100, 999); + } + + $ok = 0; + foreach ($orderIds as $oid) { + $oid = (int)$oid; + if (!Db::name('order')->where('id', $oid)->where('user_id', $this->uid())->find()) { + continue; + } + if (Db::name('merchant_order')->where('order_id', $oid)->find()) { + continue; + } + Db::name('merchant_order')->insert([ + 'merchant_id' => (int)$merchant['id'], + 'order_id' => $oid, + 'batch_no' => $batchNo, + 'createtime' => time(), + ]); + $ok++; + } + $this->success('已归属到批次', ['batch_no' => $batchNo, 'count' => $ok]); + } + + /** + * 当前用户商家档案 + */ + protected function merchantInfo() + { + return Db::name('merchant')->where('user_id', $this->uid())->find(); + } + + /** + * 要求已通过审核的商家 + */ + protected function requireMerchant() + { + $m = $this->merchantInfo(); + if (!$m) { + $this->error('请先完成商家入驻', ['need_apply' => 1], 2001); + } + if ($m['verify_status'] === 'pending') { + $this->error('商家资质审核中,请耐心等待', ['verify_status' => 'pending'], 2002); + } + if ($m['verify_status'] === 'reject') { + $this->error('资质未通过:' . $m['audit_msg'], ['verify_status' => 'reject'], 2003); + } + return $m; + } +} diff --git a/server/application/common/library/WxLogin.php b/server/application/common/library/WxLogin.php index 3fa8981..8d97e95 100644 --- a/server/application/common/library/WxLogin.php +++ b/server/application/common/library/WxLogin.php @@ -33,9 +33,16 @@ class WxLogin } $wx = json_decode($resp, true); if (empty($wx['openid'])) { - $ec = isset($wx['errcode']) ? $wx['errcode'] : '-'; + $ec = isset($wx['errcode']) ? (int)$wx['errcode'] : 0; $em = isset($wx['errmsg']) ? $wx['errmsg'] : '未知错误'; - return ['error' => '微信登录失败(' . $ec . '):' . $em]; + $hint = [ + 40029 => '小程序 appid 与服务端配置不一致:请确认 manifest.json 的 mp-weixin.appid 为 ' . $appid, + 40125 => 'appsecret 配置错误,请检查 .env 的 [wechat] secret', + 45011 => '登录请求过于频繁,请稍后重试', + 40226 => '该微信账号被风控限制,请稍后再试', + ]; + $msg = isset($hint[$ec]) ? $hint[$ec] : ('微信登录失败(' . $ec . '):' . $em); + return ['error' => $msg, 'errcode' => $ec]; } return $wx; } diff --git a/tests/phase2_verify.js b/tests/phase2_verify.js index 24f4c33..7f238f0 100644 --- a/tests/phase2_verify.js +++ b/tests/phase2_verify.js @@ -37,7 +37,7 @@ const sql = (s) => execFileSync('ssh', ['-o', 'BatchMode=yes', 'root@8.137.181.6 sec('微信授权登录'); let r = await api('/auth/wxlogin', { method: 'POST', data: { code: 'invalid_code_x' } }); - chk(r.code !== 1 && /微信登录失败/.test(r.msg || ''), '无效 code 被微信侧拒绝并回显原因', r.msg); + chk(r.code !== 1 && /微信登录失败|appid/.test(r.msg || ''), '无效 code 被微信侧拒绝并给出排查指引', r.msg); r = await api('/auth/wxlogin', { method: 'POST', data: {} }); chk(r.code !== 1, '缺少 code 时参数校验生效', r.msg); diff --git a/tests/phase3_merchant.js b/tests/phase3_merchant.js new file mode 100644 index 0000000..94738b7 --- /dev/null +++ b/tests/phase3_merchant.js @@ -0,0 +1,136 @@ +/** + * Phase 3 验证:商家(企业账号)注册 / 登录 / 全业务流程 + * 运行:node tests/phase3_merchant.js + */ +const { execFileSync } = require('child_process'); +const BASE = process.env.API_BASE || 'https://yjyf.gzyakj.com.cn/api'; + +let pass = 0, fail = 0; +const failures = []; +const ok = (m, e) => { pass++; console.log(' ✅ ' + m + (e ? ' → ' + e : '')); }; +const bad = (m, e) => { fail++; failures.push(m + ' :: ' + e); console.log(' ❌ ' + m + ' → ' + e); }; +const chk = (c, m, e) => (c ? ok(m, e) : bad(m, e || '断言失败')); +const sec = (t) => console.log('\n▌' + t); + +async function api(path, opt) { + const o = opt || {}; + const headers = {}; + if (o.token) headers.token = o.token; + let body; + if (o.data) { + headers['content-type'] = 'application/x-www-form-urlencoded'; + body = new URLSearchParams(o.data).toString(); + } + const res = await fetch(BASE + path, { method: o.method || 'GET', headers, body }); + const text = await res.text(); + try { return JSON.parse(text); } catch (e) { throw new Error('非 JSON: ' + text.slice(0, 120)); } +} +const sql = (s) => execFileSync('ssh', ['-o', 'BatchMode=yes', 'root@8.137.181.63', `mysql -uyjyf -pyjyf yjyf -e "${s}"`], { encoding: 'utf8' }).trim(); + +(async () => { + const stamp = String(Date.now()).slice(-8); + const mobile = '138' + stamp; + + console.log('═══════════════════════════════════════════════════'); + console.log(' Phase 3 验证:商家注册 / 登录 / 全业务流程'); + console.log('═══════════════════════════════════════════════════'); + + sec('账号与身份'); + const login = await api('/auth/login', { method: 'POST', data: { mobile: mobile, code: '123456' } }); + const token = login.data.token; + chk(login.code === 1, '手机号登录(微信不可用时的兜底通道)'); + chk(login.data.userinfo.is_merchant === false, '新账号默认个人用户'); + + let r = await api('/auth/switchRole', { method: 'POST', token: token, data: { role: 'merchant' } }); + chk(r.code === 2001 && r.data.need_apply === 1, '未入驻切商家被拦截(2001)', r.msg); + + sec('商家入驻'); + r = await api('/merchant/apply', { + method: 'POST', token: token, + data: { + name: '遵义优匠物业服务有限公司', license_no: '91520300MA6XXXXX1A', + legal_person: '王老板', contact: '李经理', mobile: mobile, + province: '贵州省', city: '遵义市', district: '汇川区', address: '大连路 100 号 3 楼', + invoice_title: '遵义优匠物业服务有限公司', invoice_tax_no: '91520300MA6XXXXX1A', + settle_type: 'monthly' + } + }); + chk(r.code === 1 && r.data.merchant_id > 0, '提交企业资料', 'merchant_id=' + r.data.merchant_id); + const merchantId = r.data.merchant_id; + + r = await api('/merchant/info', { token: token }); + chk(r.data.applied && r.data.merchant.verify_status === 'pending', '入驻后状态=待审核'); + chk(r.data.merchant.settle_type === 'monthly', '结算方式保存为月结'); + + r = await api('/auth/switchRole', { method: 'POST', token: token, data: { role: 'merchant' } }); + chk(r.code === 2002, '审核中切商家被拦截(2002)', r.msg); + + sql(`UPDATE fa_merchant SET verify_status='pass' WHERE id=${merchantId}`); + r = await api('/auth/switchRole', { method: 'POST', token: token, data: { role: 'merchant' } }); + chk(r.code === 1 && r.data.redirect === '/pages/merchant/workbench', '审核通过 → 切换商家端', r.data && r.data.redirect); + + sec('工作台数据'); + r = await api('/merchant/info', { token: token }); + const m = r.data.merchant; + chk(m && Number(m.order_count) === 0 && Number(m.total_amount) === 0, '初始经营数据为 0', '订单 ' + m.order_count + ' / 金额 ' + m.total_amount); + chk(m.invoiceable !== undefined, '可开票金额字段就绪', '¥' + m.invoiceable); + + sec('批量下单'); + const svc = (await api('/home/services?cat_id=1')).data.list.find(s => s.price_type === 'fixed'); + const orderIds = []; + for (const i of [1, 2]) { + const addr = await api('/address/save', { + method: 'POST', token: token, + data: { contact: '门店' + i, mobile: mobile, province: '贵州省', city: '遵义市', district: '汇川区', address: '批量测试地址 ' + i, floor: 3, has_elevator: 1, is_default: 0 } + }); + const o = await api('/order/create', { method: 'POST', token: token, data: { service_id: svc.id, address_id: addr.data.id, goods_num: 1, remark: '商家批量下单' } }); + await api('/order/pay', { method: 'POST', token: token, data: { order_id: o.data.order_id } }); + orderIds.push(o.data.order_id); + } + chk(orderIds.length === 2, '逐单创建并支付', orderIds.join(',')); + + r = await api('/merchant/bindBatch', { method: 'POST', token: token, data: { order_ids: orderIds.join(','), batch_no: '' } }); + chk(r.code === 1 && r.data.count === 2 && r.data.batch_no, '归属到同一批次', 'batch_no=' + r.data.batch_no + ' count=' + r.data.count); + + r = await api('/merchant/bindBatch', { method: 'POST', token: token, data: { order_ids: orderIds.join(','), batch_no: '' } }); + chk(r.code === 1 && r.data.count === 0, '重复归属被忽略(幂等)', 'count=' + r.data.count); + + sec('账单中心'); + r = await api('/merchant/bills', { token: token }); + chk(r.code === 1 && r.data.months.length >= 1, '按月汇总生成', r.data.months[0] && (r.data.months[0].month + ' / ' + r.data.months[0].cnt + ' 单 / ¥' + r.data.months[0].amount)); + chk(r.data.orders.length === 2, '近期订单明细', r.data.orders.length + ' 条'); + + sec('开票'); + r = await api('/merchant/info', { token: token }); + const available = Number(r.data.merchant.invoiceable); + chk(available > 0, '可开票金额已累计', '¥' + available); + + r = await api('/merchant/invoice', { method: 'POST', token: token, data: { amount: available + 100, type: 'normal' } }); + chk(r.code !== 1, '超额开票被拦截', r.msg); + + r = await api('/merchant/invoice', { method: 'POST', token: token, data: { amount: 50, type: 'special', email: 'fin@example.com' } }); + chk(r.code === 1, '提交开票申请(专用发票)', r.msg); + + r = await api('/merchant/invoices', { token: token }); + chk(r.code === 1 && r.data.list.length === 1 && r.data.list[0].type_text === '专用发票', '开票记录回显', r.data.list[0] && r.data.list[0].status_text); + + r = await api('/merchant/invoice', { method: 'POST', token: token, data: { amount: available, type: 'normal' } }); + chk(r.code !== 1, '已申请金额占用额度(防重复开票)', r.msg); + + sec('资料维护与身份切回'); + r = await api('/merchant/update', { method: 'POST', token: token, data: { contact: '新经理', mobile: mobile } }); + chk(r.code === 1, '维护联系人', r.msg); + r = await api('/merchant/info', { token: token }); + chk(r.data.merchant.contact === '新经理', '资料已更新'); + + r = await api('/auth/switchRole', { method: 'POST', token: token, data: { role: 'user' } }); + chk(r.code === 1 && r.data.redirect === '/YJYF/YJYF', '切回个人用户', r.data && r.data.redirect); + r = await api('/auth/profile', { token: token }); + chk(r.data.is_merchant === true && Number(r.data.merchant.id) === Number(merchantId), 'profile 携带商家身份', 'merchant_id=' + r.data.merchant.id); + + console.log('\n═══════════════════════════════════════════════════'); + console.log(` 结果:通过 ${pass} 项 / 失败 ${fail} 项`); + if (fail) { console.log(' 失败明细:'); failures.forEach(f => console.log(' - ' + f)); process.exitCode = 1; } + else console.log(' 🎉 商家注册/登录/批量下单/对账/开票 全流程通过'); + console.log('═══════════════════════════════════════════════════'); +})().catch(e => { console.error('\n💥 中断: ' + e.message); process.exitCode = 1; }); diff --git a/utils/api.js b/utils/api.js index e5e1277..3456ea8 100644 --- a/utils/api.js +++ b/utils/api.js @@ -54,5 +54,14 @@ export default { /* ---------- 评价 ---------- */ reviewCreate: (data) => post('/review/create', data), - reviewList: (masterId) => get('/review/masterList', { master_id: masterId }) + reviewList: (masterId) => get('/review/masterList', { master_id: masterId }), + + /* ---------- 商家端 ---------- */ + merchantApply: (data) => post('/merchant/apply', data), + merchantInfo: () => get('/merchant/info'), + merchantUpdate: (data) => post('/merchant/update', data), + merchantBills: () => get('/merchant/bills'), + merchantInvoices: () => get('/merchant/invoices'), + merchantInvoice: (data) => post('/merchant/invoice', data), + merchantBindBatch: (data) => post('/merchant/bindBatch', data) };