'开票中', 'issued' => '已开票', 'reject' => '已驳回', 'cancel' => '已撤回', ]; } /** 发票类型文案映射 */ public static function typeMap() { return [ 'normal' => '增值税普通发票', 'special' => '增值税专用发票', ]; } /** 抬头类型文案映射 */ public static function titleTypeMap() { return [ 'personal' => '个人/非企业', 'enterprise' => '企业单位', ]; } /** * 解析当前用户的开票主体上下文(个人用户 vs 已认证商家主账号/关联账号) * @param int $uid * @return array */ public static function resolveContext($uid) { $uid = (int)$uid; $merchant = Db::name('merchant') ->field('id,user_id,name,license_no,contact,mobile,province,city,district,address,invoice_title,invoice_tax_no,invoice_bank,invoice_account,email,verify_status') ->where('user_id', $uid) ->find(); if (!$merchant) { $bind = Db::name('merchant_member') ->field('id,merchant_id,user_id') ->where('user_id', $uid) ->find(); if ($bind) { $merchant = Db::name('merchant') ->field('id,user_id,name,license_no,contact,mobile,province,city,district,address,invoice_title,invoice_tax_no,invoice_bank,invoice_account,email,verify_status') ->where('id', (int)$bind['merchant_id']) ->find(); } } $isPassMerchant = ($merchant && $merchant['verify_status'] === 'pass'); $uids = [$uid]; $merchantId = 0; if ($isPassMerchant) { $merchantId = (int)$merchant['id']; $uids = [(int)$merchant['user_id']]; $memberUids = Db::name('merchant_member') ->where('merchant_id', $merchantId) ->column('user_id'); foreach ($memberUids as $mUid) { $mUid = (int)$mUid; if ($mUid > 0 && !in_array($mUid, $uids, true)) { $uids[] = $mUid; } } } return [ 'uid' => $uid, 'is_merchant' => $isPassMerchant, 'merchant_id' => $merchantId, 'merchant' => $merchant ?: null, 'uids' => $uids, ]; } /** * 计算单笔订单可开票净额(实付总额含已付补差价 - 已退金额) * @param array $order * @return float */ public static function calcOrderNetAmount(array $order) { if ((int)$order['pay_status'] !== 1 || (int)$order['status'] !== OrderStatus::COMPLETED) { return 0.00; } $pay = (float)$order['pay_amount']; $refund = isset($order['refund_amount']) ? (float)$order['refund_amount'] : 0.00; return round(max(0, $pay - $refund), 2); } /** * 构造发票归属查询闭包 * @param array $ctx * @return \Closure */ public static function buildInvoiceScope(array $ctx) { return function ($q) use ($ctx) { if ($ctx['is_merchant'] && $ctx['merchant_id'] > 0) { $q->where('merchant_id', $ctx['merchant_id']) ->whereOr('user_id', 'in', $ctx['uids']); } else { $q->where('user_id', $ctx['uid']); } }; } /** * 获取统一的可开票额度、统计及默认抬头信息 * @param int $uid * @return array */ public static function getQuotaSummary($uid) { $ctx = self::resolveContext($uid); $uids = $ctx['uids']; $scope = self::buildInvoiceScope($ctx); // 已完成订单净额汇总(单条聚合 SQL,统一按已完成订单核算可开票总额) $orderAgg = Db::name('order') ->field('COUNT(*) AS total_cnt, SUM(CASE WHEN invoice_status = "none" THEN 1 ELSE 0 END) AS unbilled_cnt, SUM(GREATEST(0, pay_amount - refund_amount)) AS net_paid') ->where('user_id', 'in', $uids) ->where('pay_status', 1) ->where('status', OrderStatus::COMPLETED) ->find(); $paidAmount = $orderAgg && $orderAgg['net_paid'] !== null ? round((float)$orderAgg['net_paid'], 2) : 0.00; $unbilledOrderCount = $orderAgg ? (int)$orderAgg['unbilled_cnt'] : 0; // 发票各状态统计(单条聚合 SQL,兼容 wait 与 issued/done 占用口径) $invAgg = Db::name('invoice') ->field('COUNT(*) AS record_cnt, SUM(CASE WHEN status = "wait" THEN 1 ELSE 0 END) AS wait_cnt, SUM(CASE WHEN status IN ("wait", "issued", "done") THEN amount ELSE 0 END) AS used_amt, SUM(CASE WHEN status IN ("issued", "done") THEN amount ELSE 0 END) AS issued_amt, SUM(CASE WHEN status = "wait" THEN amount ELSE 0 END) AS wait_amt') ->where($scope) ->find(); $usedAmount = $invAgg && $invAgg['used_amt'] !== null ? round((float)$invAgg['used_amt'], 2) : 0.00; $issuedAmount = $invAgg && $invAgg['issued_amt'] !== null ? round((float)$invAgg['issued_amt'], 2) : 0.00; $waitAmount = $invAgg && $invAgg['wait_amt'] !== null ? round((float)$invAgg['wait_amt'], 2) : 0.00; $recordCount = $invAgg ? (int)$invAgg['record_cnt'] : 0; $waitCount = $invAgg ? (int)$invAgg['wait_cnt'] : 0; $available = round(max(0, $paidAmount - $usedAmount), 2); // 确保认证商家首次自动同步入驻开票抬头 self::ensureMerchantDefaultTitle($ctx); $titles = self::getTitleList($uid, $ctx); $defaultTitleRow = !empty($titles) ? $titles[0] : null; $userRow = Db::name('user') ->field('id,nickname,mobile,email') ->where('id', $ctx['uid']) ->find(); $personalTitle = ($userRow && !empty($userRow['nickname'])) ? (string)$userRow['nickname'] : '个人'; $merchant = $ctx['merchant']; $entTitle = $merchant ? (string)($merchant['invoice_title'] ?: $merchant['name']) : ''; $entTaxNo = $merchant ? (string)($merchant['invoice_tax_no'] ?: $merchant['license_no']) : ''; return [ 'available' => $available, 'paid_amount' => $paidAmount, 'used_amount' => $usedAmount, 'issued_amount' => $issuedAmount, 'wait_amount' => $waitAmount, 'unbilled_order_count' => $unbilledOrderCount, 'record_count' => $recordCount, 'wait_count' => $waitCount, 'title_count' => count($titles), 'is_merchant' => $ctx['is_merchant'], 'default_title_obj' => $defaultTitleRow, 'default_title_type' => $defaultTitleRow ? $defaultTitleRow['title_type'] : ($ctx['is_merchant'] ? 'enterprise' : 'personal'), 'default_title' => $defaultTitleRow ? $defaultTitleRow['title'] : ($ctx['is_merchant'] ? $entTitle : $personalTitle), 'default_tax_no' => $defaultTitleRow ? $defaultTitleRow['tax_no'] : ($ctx['is_merchant'] ? $entTaxNo : ''), 'default_email' => ($defaultTitleRow && !empty($defaultTitleRow['email'])) ? $defaultTitleRow['email'] : (($userRow && !empty($userRow['email'])) ? (string)$userRow['email'] : ($merchant && !empty($merchant['email']) ? (string)$merchant['email'] : '')), 'default_mobile' => ($defaultTitleRow && !empty($defaultTitleRow['receive_mobile'])) ? $defaultTitleRow['receive_mobile'] : (($userRow && !empty($userRow['mobile'])) ? (string)$userRow['mobile'] : ''), 'enterprise_title' => $entTitle, 'enterprise_tax_no' => $entTaxNo, 'personal_title' => $personalTitle, ]; } /** * 若认证商家尚未在 fa_invoice_title 落库企业抬头,自动根据商家真实入驻档案初始化一条默认抬头 * @param array $ctx */ public static function ensureMerchantDefaultTitle(array $ctx) { if (!$ctx['is_merchant'] || !$ctx['merchant']) { return; } $m = $ctx['merchant']; $mid = (int)$m['id']; $titleName = trim((string)($m['invoice_title'] ?: $m['name'])); $taxNo = strtoupper(trim((string)($m['invoice_tax_no'] ?: $m['license_no']))); if ($titleName === '' || $taxNo === '') { return; } $exists = Db::name('invoice_title') ->field('id') ->where(function ($q) use ($mid, $ctx) { $q->where('merchant_id', $mid)->whereOr('user_id', 'in', $ctx['uids']); }) ->where('title_type', 'enterprise') ->find(); if ($exists) { return; } $fullAddr = trim((string)$m['province'] . (string)$m['city'] . (string)$m['district'] . (string)$m['address']); $hasDefault = Db::name('invoice_title') ->where('user_id', 'in', $ctx['uids']) ->where('is_default', 1) ->count() > 0; $hasSpecialInfo = !empty($m['invoice_bank']) && !empty($m['invoice_account']) && $fullAddr !== '' && !empty($m['mobile']); Db::name('invoice_title')->insert([ 'user_id' => (int)$m['user_id'], 'merchant_id' => $mid, 'title_type' => 'enterprise', 'default_invoice_type' => $hasSpecialInfo ? 'special' : 'normal', 'title' => $titleName, 'tax_no' => $taxNo, 'bank_name' => trim((string)$m['invoice_bank']), 'bank_account' => trim((string)$m['invoice_account']), 'reg_address' => $fullAddr, 'reg_mobile' => trim((string)$m['mobile']), 'email' => trim((string)$m['email']), 'receive_mobile' => trim((string)$m['mobile']), 'is_default' => $hasDefault ? 0 : 1, 'createtime' => time(), 'updatetime' => time(), ]); } /** * 获取当前用户/关联商户可见的抬头列表(默认置顶) * @param int $uid * @param array|null $ctx * @return array */ public static function getTitleList($uid, $ctx = null) { $ctx = $ctx ?: self::resolveContext($uid); self::ensureMerchantDefaultTitle($ctx); $rows = Db::name('invoice_title') ->field('id,user_id,merchant_id,title_type,default_invoice_type,title,tax_no,bank_name,bank_account,reg_address,reg_mobile,email,receive_mobile,is_default,createtime,updatetime') ->where(function ($q) use ($ctx) { if ($ctx['is_merchant'] && $ctx['merchant_id'] > 0) { $q->where('merchant_id', $ctx['merchant_id']) ->whereOr('user_id', 'in', $ctx['uids']); } else { $q->where('user_id', $ctx['uid']); } }) ->order('is_default desc, id desc') ->select(); foreach ($rows as &$row) { $row = self::formatTitleRow($row); } unset($row); return $rows; } /** * 校验并获取单条抬头详情(防 IDOR 越权) * @param int $uid * @param int $id * @return array */ public static function getOwnTitle($uid, $id) { $id = (int)$id; if ($id <= 0) { throw new \InvalidArgumentException('抬头参数错误'); } $ctx = self::resolveContext($uid); $row = Db::name('invoice_title') ->field('id,user_id,merchant_id,title_type,default_invoice_type,title,tax_no,bank_name,bank_account,reg_address,reg_mobile,email,receive_mobile,is_default,createtime,updatetime') ->where('id', $id) ->where(function ($q) use ($ctx) { if ($ctx['is_merchant'] && $ctx['merchant_id'] > 0) { $q->where('merchant_id', $ctx['merchant_id']) ->whereOr('user_id', 'in', $ctx['uids']); } else { $q->where('user_id', $ctx['uid']); } }) ->find(); if (!$row) { throw new \InvalidArgumentException('发票抬头不存在或无权操作'); } return self::formatTitleRow($row); } /** * 新增或编辑发票抬头 * @param int $uid * @param array $input * @return array */ public static function saveTitle($uid, array $input) { $ctx = self::resolveContext($uid); $id = isset($input['id']) ? (int)$input['id'] : 0; $titleType = isset($input['title_type']) ? trim((string)$input['title_type']) : 'personal'; if (!in_array($titleType, ['personal', 'enterprise'], true)) { throw new \InvalidArgumentException('抬头类型不正确'); } $defaultInvType = isset($input['default_invoice_type']) ? trim((string)$input['default_invoice_type']) : 'normal'; if (!in_array($defaultInvType, ['normal', 'special'], true)) { $defaultInvType = 'normal'; } if ($titleType === 'personal') { $defaultInvType = 'normal'; } $title = trim((string)($input['title'] ?? '')); $taxNo = strtoupper(preg_replace('/\s+/', '', (string)($input['tax_no'] ?? ''))); $bankName = trim((string)($input['bank_name'] ?? '')); $bankAccount = preg_replace('/\s+/', '', (string)($input['bank_account'] ?? '')); $regAddress = trim((string)($input['reg_address'] ?? '')); $regMobile = trim((string)($input['reg_mobile'] ?? '')); $email = trim((string)($input['email'] ?? '')); $receiveMobile = trim((string)($input['receive_mobile'] ?? '')); $isDefault = !empty($input['is_default']) ? 1 : 0; if ($title === '') { throw new \InvalidArgumentException($titleType === 'enterprise' ? '请填写企业名称(发票抬头)' : '请填写发票抬头名称'); } if ($titleType === 'enterprise') { if ($taxNo === '') { throw new \InvalidArgumentException('请填写纳税人识别号(统一社会信用代码)'); } if (!preg_match('/^[0-9A-Z]{15,20}$/', $taxNo)) { throw new \InvalidArgumentException('纳税人识别号需为15-20位字母或数字'); } if ($defaultInvType === 'special') { if ($bankName === '' || $bankAccount === '') { throw new \InvalidArgumentException('专用发票抬头需填写完整的开户银行与银行账号'); } if ($regAddress === '' || $regMobile === '') { throw new \InvalidArgumentException('专用发票抬头需填写完整的企业注册地址与注册电话'); } } } else { $taxNo = ''; $bankName = ''; $bankAccount = ''; $regAddress = ''; $regMobile = ''; } if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) { throw new \InvalidArgumentException('接收邮箱格式不正确'); } if ($receiveMobile !== '' && !preg_match('/^1\d{10}$/', $receiveMobile)) { throw new \InvalidArgumentException('通知手机号格式不正确'); } $saveData = [ 'title_type' => $titleType, 'default_invoice_type' => $defaultInvType, 'title' => mb_substr($title, 0, 100), 'tax_no' => mb_substr($taxNo, 0, 50), 'bank_name' => mb_substr($bankName, 0, 100), 'bank_account' => mb_substr($bankAccount, 0, 50), 'reg_address' => mb_substr($regAddress, 0, 255), 'reg_mobile' => mb_substr($regMobile, 0, 30), 'email' => mb_substr($email, 0, 100), 'receive_mobile' => mb_substr($receiveMobile, 0, 20), 'is_default' => $isDefault, 'updatetime' => time(), ]; Db::startTrans(); try { if ($id > 0) { $old = self::getOwnTitle($uid, $id); Db::name('invoice_title')->where('id', (int)$old['id'])->update($saveData); } else { $cnt = (int)Db::name('invoice_title')->where('user_id', $ctx['uid'])->count(); if ($cnt >= 30) { throw new \InvalidArgumentException('最多可保存 30 个常用发票抬头'); } if ($cnt === 0 && !$ctx['is_merchant']) { $saveData['is_default'] = 1; } $saveData['user_id'] = $ctx['uid']; $saveData['merchant_id'] = ($ctx['is_merchant'] && $titleType === 'enterprise') ? $ctx['merchant_id'] : 0; $saveData['createtime'] = time(); $id = (int)Db::name('invoice_title')->insertGetId($saveData); } if ((int)$saveData['is_default'] === 1) { $otherIds = Db::name('invoice_title') ->where(self::buildInvoiceScope($ctx)) ->where('id', '<>', $id) ->column('id'); if (!empty($otherIds)) { Db::name('invoice_title') ->where('id', 'in', array_map('intval', $otherIds)) ->update(['is_default' => 0, 'updatetime' => time()]); } } Db::commit(); } catch (\Exception $e) { Db::rollback(); throw $e; } return self::getOwnTitle($uid, $id); } /** * 设为默认抬头 * @param int $uid * @param int $id */ public static function setDefaultTitle($uid, $id) { $ctx = self::resolveContext($uid); $target = self::getOwnTitle($uid, $id); $targetId = (int)$target['id']; $now = time(); Db::startTrans(); try { $otherIds = Db::name('invoice_title') ->where(self::buildInvoiceScope($ctx)) ->where('id', '<>', $targetId) ->column('id'); if (!empty($otherIds)) { Db::name('invoice_title') ->where('id', 'in', array_map('intval', $otherIds)) ->update(['is_default' => 0, 'updatetime' => $now]); } Db::name('invoice_title') ->where('id', $targetId) ->update(['is_default' => 1, 'updatetime' => $now]); Db::commit(); } catch (\Exception $e) { Db::rollback(); throw $e; } } /** * 删除发票抬头 * @param int $uid * @param int $id */ public static function deleteTitle($uid, $id) { $target = self::getOwnTitle($uid, $id); Db::name('invoice_title')->where('id', (int)$target['id'])->delete(); } /** * 获取可勾选开票的已完成未开票订单列表(支持传入 filterIds 精准查询指定待开票订单) * @param int $uid * @param int $page * @param int $limit * @param int[] $filterIds * @return array */ public static function getInvoiceableOrders($uid, $page = 1, $limit = 20, array $filterIds = []) { $ctx = self::resolveContext($uid); $uids = $ctx['uids']; $page = max(1, (int)$page); $limit = min(100, max(1, (int)$limit)); $filterIds = self::parseIds($filterIds); $baseQuery = function () use ($uids, $filterIds) { $q = Db::name('order') ->where('user_id', 'in', $uids) ->where('pay_status', 1) ->where('status', OrderStatus::COMPLETED) ->where('invoice_status', 'none'); if (!empty($filterIds)) { $q->where('id', 'in', $filterIds); } return $q; }; $total = (int)$baseQuery()->count(); $rows = $baseQuery() ->field('id,order_no,user_id,service_id,service_name,service_image,goods_model,goods_num,pay_amount,refund_amount,pay_status,status,pay_time,verify_time,createtime') ->order('id desc') ->page($page, $limit) ->select(); $list = []; $sumAmount = 0.00; foreach ($rows as $row) { $net = self::calcOrderNetAmount($row); if ($net <= 0) { continue; } $sumAmount = round($sumAmount + $net, 2); $img = !empty($row['service_image']) ? (string)$row['service_image'] : ''; if ($img !== '' && strpos($img, 'http') !== 0 && strpos($img, '/static/') !== 0) { $img = cdnurl($img, true); } $doneTime = (int)($row['verify_time'] ?: ($row['pay_time'] ?: $row['createtime'])); $list[] = [ 'id' => (int)$row['id'], 'order_no' => (string)$row['order_no'], 'service_name' => (string)($row['service_name'] ?: '上门安装维修服务'), 'goods_model' => (string)$row['goods_model'], 'goods_num' => max(1, (int)$row['goods_num']), 'service_image' => $img, 'status' => (int)$row['status'], 'status_text' => OrderStatus::label((int)$row['status']), 'invoiceable_amount' => $net, 'time_text' => date('Y-m-d H:i', $doneTime), ]; } return [ 'list' => $list, 'total' => $total, 'selected_amount' => $sumAmount, 'page' => $page, 'limit' => $limit, 'has_more' => ($page * $limit) < $total, ]; } /** * 提交开票申请(兼容:按勾选订单合并开票 & 按可用额度开票) * @param int $uid * @param array $params * @param bool $strictSpecial 是否严格强制校验增值税专票六要素(新接口为 true,旧兼容接口为 false) * @return array */ public static function submitInvoice($uid, array $params, $strictSpecial = true) { $ctx = self::resolveContext($uid); $merchant = $ctx['merchant']; $isPassMerchant = $ctx['is_merchant']; // 解析关联订单 ID 集合 $orderIds = self::parseIds($params['order_ids'] ?? []); $titleId = isset($params['title_id']) ? (int)$params['title_id'] : 0; $type = isset($params['type']) ? trim((string)$params['type']) : 'normal'; $titleType = isset($params['title_type']) ? trim((string)$params['title_type']) : ''; $title = trim((string)($params['title'] ?? '')); $taxNo = strtoupper(preg_replace('/\s+/', '', (string)($params['tax_no'] ?? ''))); $bankName = trim((string)($params['bank_name'] ?? '')); $bankAccount = preg_replace('/\s+/', '', (string)($params['bank_account'] ?? '')); $regAddress = trim((string)($params['reg_address'] ?? '')); $regMobile = trim((string)($params['reg_mobile'] ?? '')); $email = trim((string)($params['email'] ?? '')); $receiveMobile = trim((string)($params['receive_mobile'] ?? '')); $remark = trim((string)($params['remark'] ?? '')); $saveToTitleBook = !empty($params['save_title']); if (!in_array($type, ['normal', 'special'], true)) { throw new \InvalidArgumentException('发票类型错误'); } // 若指定了 title_id,读取抬头簿补全缺省项 if ($titleId > 0) { $tRow = self::getOwnTitle($uid, $titleId); if ($titleType === '') $titleType = $tRow['title_type']; if ($title === '') $title = $tRow['title']; if ($taxNo === '') $taxNo = $tRow['tax_no']; if ($bankName === '') $bankName = $tRow['bank_name']; if ($bankAccount === '') $bankAccount = $tRow['bank_account']; if ($regAddress === '') $regAddress = $tRow['reg_address']; if ($regMobile === '') $regMobile = $tRow['reg_mobile']; if ($email === '' && !empty($tRow['email'])) $email = $tRow['email']; if ($receiveMobile === '' && !empty($tRow['receive_mobile'])) $receiveMobile = $tRow['receive_mobile']; } if (!in_array($titleType, ['personal', 'enterprise'], true)) { $titleType = ($isPassMerchant || $taxNo !== '' || $type === 'special') ? 'enterprise' : 'personal'; } if ($titleType === 'personal') { if ($type === 'special') { throw new \InvalidArgumentException('个人/非企业抬头仅支持开具普通发票'); } if ($title === '') { $uRow = Db::name('user')->field('nickname')->where('id', $ctx['uid'])->find(); $title = ($uRow && !empty($uRow['nickname'])) ? (string)$uRow['nickname'] : '个人'; } $taxNo = ''; $bankName = ''; $bankAccount = ''; $regAddress = ''; $regMobile = ''; } else { // 企业单位:若未传且是认证商家,自动回退读取商家档案 if ($isPassMerchant && $merchant) { if ($title === '') { $title = trim((string)($merchant['invoice_title'] ?: $merchant['name'])); } if ($taxNo === '') { $taxNo = strtoupper(trim((string)($merchant['invoice_tax_no'] ?: $merchant['license_no']))); } if ($bankName === '' && !empty($merchant['invoice_bank'])) { $bankName = trim((string)$merchant['invoice_bank']); } if ($bankAccount === '' && !empty($merchant['invoice_account'])) { $bankAccount = trim((string)$merchant['invoice_account']); } if ($regAddress === '') { $regAddress = trim((string)$merchant['province'] . (string)$merchant['city'] . (string)$merchant['district'] . (string)$merchant['address']); } if ($regMobile === '' && !empty($merchant['mobile'])) { $regMobile = trim((string)$merchant['mobile']); } } if ($title === '') { throw new \InvalidArgumentException('请填写企业发票抬头'); } if ($taxNo === '') { throw new \InvalidArgumentException('请填写纳税人识别号(统一社会信用代码)'); } if ($strictSpecial && !preg_match('/^[0-9A-Z]{15,20}$/', $taxNo)) { throw new \InvalidArgumentException('纳税人识别号需为15-20位字母或数字'); } if ($type === 'special' && $strictSpecial) { if ($bankName === '' || $bankAccount === '') { throw new \InvalidArgumentException('开具增值税专用发票须填写开户银行及银行账号'); } if ($regAddress === '' || $regMobile === '') { throw new \InvalidArgumentException('开具增值税专用发票须填写企业注册地址及注册电话'); } } } if ($strictSpecial && $email === '') { throw new \InvalidArgumentException('请填写接收电子发票的邮箱地址'); } if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) { throw new \InvalidArgumentException('接收邮箱格式不正确'); } if ($receiveMobile !== '' && !preg_match('/^1\d{10}$/', $receiveMobile)) { throw new \InvalidArgumentException('通知手机号格式不正确'); } $inputAmount = isset($params['amount']) ? round((float)$params['amount'], 2) : 0.00; if ($strictSpecial && empty($orderIds)) { throw new \InvalidArgumentException('请先选择需要开具发票的已完成订单'); } if (empty($orderIds) && $inputAmount <= 0) { throw new \InvalidArgumentException('请选择开票订单'); } $scope = self::buildInvoiceScope($ctx); $uids = $ctx['uids']; $invoiceId = 0; $invoiceSn = ''; $finalAmount = $inputAmount; Db::startTrans(); try { // 锁定用户名片行作为用户/商户级开票互斥锚点,防止并发双花超开额度 $anchorUid = $isPassMerchant && $merchant ? (int)$merchant['user_id'] : $ctx['uid']; Db::name('user')->field('id')->where('id', $anchorUid)->lock(true)->find(); if (!empty($orderIds)) { $lockedOrders = Db::name('order') ->field('id,order_no,user_id,pay_amount,diff_amount,diff_status,refund_amount,pay_status,status,invoice_status') ->where('id', 'in', $orderIds) ->where('user_id', 'in', $uids) ->lock(true) ->select(); if (count($lockedOrders) !== count($orderIds)) { throw new \InvalidArgumentException('部分所选订单不存在或无权开票'); } $sumOrders = 0.00; foreach ($lockedOrders as $ord) { if ((int)$ord['status'] !== OrderStatus::COMPLETED) { throw new \InvalidArgumentException('订单「' . $ord['order_no'] . '」尚未完成验收,仅已完成订单可申请开票'); } if ($ord['invoice_status'] !== 'none') { throw new \InvalidArgumentException('订单「' . $ord['order_no'] . '」已在开票中或已开票,请勿重复选择'); } $net = self::calcOrderNetAmount($ord); if ($net <= 0) { throw new \InvalidArgumentException('订单「' . $ord['order_no'] . '」未支付或已退款,无法开票'); } $sumOrders = round($sumOrders + $net, 2); } $finalAmount = $sumOrders; } // 重新计算实时总可开票余额池(统一为已完成订单总额减去 wait 和 issued/done 占用金额) $orderAgg = Db::name('order') ->field('SUM(GREATEST(0, pay_amount - refund_amount)) AS net_paid') ->where('user_id', 'in', $uids) ->where('pay_status', 1) ->where('status', OrderStatus::COMPLETED) ->find(); $paidTotal = $orderAgg && $orderAgg['net_paid'] !== null ? round((float)$orderAgg['net_paid'], 2) : 0.00; $usedTotal = (float)Db::name('invoice') ->where($scope) ->where('status', 'in', ['wait', 'issued', 'done']) ->sum('amount'); $available = round(max(0, $paidTotal - $usedTotal), 2); if ($finalAmount <= 0) { throw new \InvalidArgumentException('有效开票金额必须大于 0'); } if ($finalAmount > $available) { // 若历史曾用不绑单额度开过部分票,而用户本次按单勾选了超出剩余总额度,给出清晰提示或支持按最大可用额度裁剪 throw new \InvalidArgumentException('可开票金额不足,当前剩余可开额度 ¥' . number_format($available, 2, '.', '')); } $now = time(); $invoiceSn = 'INV' . date('YmdHis', $now) . mt_rand(1000, 9999); // 若勾选了保存至抬头簿(且当前未指定已有 title_id) if ($saveToTitleBook && $titleId <= 0) { $dupTitle = Db::name('invoice_title') ->field('id') ->where('user_id', $ctx['uid']) ->where('title_type', $titleType) ->where('title', $title) ->find(); if ($dupTitle) { $titleId = (int)$dupTitle['id']; Db::name('invoice_title')->where('id', $titleId)->update([ 'tax_no' => mb_substr($taxNo, 0, 50), 'default_invoice_type' => $type, 'bank_name' => mb_substr($bankName, 0, 100), 'bank_account' => mb_substr($bankAccount, 0, 50), 'reg_address' => mb_substr($regAddress, 0, 255), 'reg_mobile' => mb_substr($regMobile, 0, 30), 'email' => $email !== '' ? mb_substr($email, 0, 100) : '', 'receive_mobile' => $receiveMobile !== '' ? mb_substr($receiveMobile, 0, 20) : '', 'updatetime' => $now, ]); } else { $hasAny = Db::name('invoice_title')->where('user_id', $ctx['uid'])->count() > 0; $titleId = (int)Db::name('invoice_title')->insertGetId([ 'user_id' => $ctx['uid'], 'merchant_id' => ($isPassMerchant && $titleType === 'enterprise') ? $ctx['merchant_id'] : 0, 'title_type' => $titleType, 'default_invoice_type' => $type, 'title' => mb_substr($title, 0, 100), 'tax_no' => mb_substr($taxNo, 0, 50), 'bank_name' => mb_substr($bankName, 0, 100), 'bank_account' => mb_substr($bankAccount, 0, 50), 'reg_address' => mb_substr($regAddress, 0, 255), 'reg_mobile' => mb_substr($regMobile, 0, 30), 'email' => mb_substr($email, 0, 100), 'receive_mobile' => mb_substr($receiveMobile, 0, 20), 'is_default' => $hasAny ? 0 : 1, 'createtime' => $now, 'updatetime' => $now, ]); } } $invoiceId = (int)Db::name('invoice')->insertGetId([ 'invoice_sn' => $invoiceSn, 'merchant_id' => $isPassMerchant ? $ctx['merchant_id'] : 0, 'user_id' => $ctx['uid'], 'title_id' => $titleId, 'amount' => $finalAmount, 'type' => $type, 'title_type' => $titleType, 'title' => mb_substr($title, 0, 100), 'tax_no' => mb_substr($taxNo, 0, 50), 'bank_name' => mb_substr($bankName, 0, 100), 'bank_account' => mb_substr($bankAccount, 0, 50), 'reg_address' => mb_substr($regAddress, 0, 255), 'reg_mobile' => mb_substr($regMobile, 0, 30), 'email' => mb_substr($email, 0, 100), 'receive_mobile' => mb_substr($receiveMobile, 0, 20), 'remark' => mb_substr($remark, 0, 255), 'order_ids' => !empty($orderIds) ? implode(',', $orderIds) : '', 'order_count' => count($orderIds), 'status' => 'wait', 'createtime' => $now, 'updatetime' => $now, ]); if (!empty($orderIds)) { Db::name('order') ->where('id', 'in', $orderIds) ->update([ 'invoice_id' => $invoiceId, 'invoice_status' => 'wait', 'updatetime' => $now, ]); } Db::commit(); } catch (\Exception $e) { Db::rollback(); throw $e; } return [ 'invoice_id' => $invoiceId, 'invoice_sn' => $invoiceSn, 'amount' => $finalAmount, 'order_count' => count($orderIds), ]; } /** * 获取开票记录分页列表 * @param int $uid * @param string $status all|wait|issued|reject|cancel * @param int $page * @param int $limit * @return array */ public static function getRecordList($uid, $status = 'all', $page = 1, $limit = 20) { $ctx = self::resolveContext($uid); $scope = self::buildInvoiceScope($ctx); $page = max(1, (int)$page); $limit = min(50, max(1, (int)$limit)); $build = function () use ($scope, $status) { $q = Db::name('invoice')->where($scope); if (in_array($status, ['wait', 'issued', 'reject', 'cancel'], true)) { $q->where('status', $status); } return $q; }; $total = (int)$build()->count(); $rows = $build() ->field('id,invoice_sn,merchant_id,user_id,title_id,amount,type,title_type,title,tax_no,bank_name,bank_account,reg_address,reg_mobile,email,receive_mobile,remark,order_ids,order_count,status,invoice_code,invoice_no,invoice_file,audit_msg,issue_time,createtime,updatetime') ->order('id desc') ->page($page, $limit) ->select(); foreach ($rows as &$row) { $row = self::formatInvoiceRow($row); } unset($row); return [ 'list' => $rows, 'total' => $total, 'page' => $page, 'limit' => $limit, 'has_more' => ($page * $limit) < $total, ]; } /** * 获取单笔开票详情及关联订单明细(防 IDOR 越权,批量查订单杜绝 N+1) * @param int $uid * @param int $id * @return array */ public static function getInvoiceDetail($uid, $id) { $id = (int)$id; if ($id <= 0) { throw new \InvalidArgumentException('开票申请ID错误'); } $ctx = self::resolveContext($uid); $scope = self::buildInvoiceScope($ctx); $row = Db::name('invoice') ->field('id,invoice_sn,merchant_id,user_id,title_id,amount,type,title_type,title,tax_no,bank_name,bank_account,reg_address,reg_mobile,email,receive_mobile,remark,order_ids,order_count,status,invoice_code,invoice_no,invoice_file,audit_msg,issue_time,createtime,updatetime') ->where('id', $id) ->where($scope) ->find(); if (!$row) { throw new \InvalidArgumentException('开票记录不存在或无权查看'); } $detail = self::formatInvoiceRow($row); $detail['orders'] = self::fetchLinkedOrders($row['order_ids'], (int)$row['id']); return $detail; } /** * 批量读取发票关联的订单列表(供用户端详情与后台详情复用) * @param string $orderIdsStr * @param int $invoiceId * @return array */ public static function fetchLinkedOrders($orderIdsStr, $invoiceId = 0) { $ids = self::parseIds($orderIdsStr); if (empty($ids) && $invoiceId > 0) { $ids = Db::name('order')->where('invoice_id', (int)$invoiceId)->column('id'); $ids = array_values(array_unique(array_map('intval', $ids))); } if (empty($ids)) { return []; } $orders = Db::name('order') ->field('id,order_no,service_name,goods_model,goods_num,pay_amount,diff_amount,diff_status,refund_amount,status,pay_status,createtime') ->where('id', 'in', $ids) ->order('id desc') ->select(); $res = []; foreach ($orders as $o) { $res[] = [ 'id' => (int)$o['id'], 'order_no' => (string)$o['order_no'], 'service_name' => (string)($o['service_name'] ?: '上门服务'), 'goods_model' => (string)$o['goods_model'], 'goods_num' => max(1, (int)$o['goods_num']), 'amount' => self::calcOrderNetAmount($o) ?: round((float)$o['pay_amount'], 2), 'status_text' => OrderStatus::label((int)$o['status']), 'time_text' => date('Y-m-d H:i', (int)$o['createtime']), ]; } return $res; } /** * 用户主动撤回待处理(wait)开票申请,释放额度与订单锁 * @param int $uid * @param int $id */ public static function cancelInvoice($uid, $id) { $id = (int)$id; if ($id <= 0) { throw new \InvalidArgumentException('参数错误'); } $ctx = self::resolveContext($uid); $scope = self::buildInvoiceScope($ctx); $row = Db::name('invoice') ->field('id,status,order_ids') ->where('id', $id) ->where($scope) ->find(); if (!$row) { throw new \InvalidArgumentException('开票申请不存在或无权操作'); } if ($row['status'] !== 'wait') { throw new \InvalidArgumentException('当前状态不可撤回'); } $now = time(); Db::startTrans(); try { $affected = Db::name('invoice') ->where('id', $id) ->where('status', 'wait') ->update([ 'status' => 'cancel', 'updatetime' => $now, ]); if (!$affected) { throw new \InvalidArgumentException('申请状态已变更,请刷新后重试'); } self::releaseInvoiceOrders($id, $row['order_ids'], $now); Db::commit(); } catch (\Exception $e) { Db::rollback(); throw $e; } } /** * 释放开票申请绑定的订单锁(用于用户撤回或财务驳回) * @param int $invoiceId * @param string $orderIdsStr * @param int $now */ public static function releaseInvoiceOrders($invoiceId, $orderIdsStr = '', $now = 0) { $now = $now ?: time(); $ids = self::parseIds($orderIdsStr); Db::name('order') ->where('invoice_id', (int)$invoiceId) ->update([ 'invoice_id' => 0, 'invoice_status' => 'none', 'updatetime' => $now, ]); if (!empty($ids)) { Db::name('order') ->where('id', 'in', $ids) ->where('invoice_id', 'in', [0, (int)$invoiceId]) ->where('invoice_status', 'wait') ->update([ 'invoice_id' => 0, 'invoice_status' => 'none', 'updatetime' => $now, ]); } } /** * 标记开票申请绑定的订单为已开票(用于财务确认开票完成) * @param int $invoiceId * @param string $orderIdsStr * @param int $now */ public static function markInvoiceOrdersIssued($invoiceId, $orderIdsStr = '', $now = 0) { $now = $now ?: time(); $ids = self::parseIds($orderIdsStr); Db::name('order') ->where('invoice_id', (int)$invoiceId) ->update([ 'invoice_status' => 'issued', 'updatetime' => $now, ]); if (!empty($ids)) { Db::name('order') ->where('id', 'in', $ids) ->where('invoice_id', 'in', [0, (int)$invoiceId]) ->update([ 'invoice_id' => (int)$invoiceId, 'invoice_status' => 'issued', 'updatetime' => $now, ]); } } /** * 格式化抬头行输出 * @param array $row * @return array */ public static function formatTitleRow(array $row) { $titleType = $row['title_type'] === 'enterprise' ? 'enterprise' : 'personal'; $hasSpecialInfo = $titleType === 'enterprise' && !empty($row['tax_no']) && !empty($row['bank_name']) && !empty($row['bank_account']) && !empty($row['reg_address']) && !empty($row['reg_mobile']); return [ 'id' => (int)$row['id'], 'user_id' => (int)$row['user_id'], 'merchant_id' => (int)$row['merchant_id'], 'title_type' => $titleType, 'title_type_text' => $titleType === 'enterprise' ? '企业单位' : '个人/非企业', 'default_invoice_type' => $row['default_invoice_type'] === 'special' ? 'special' : 'normal', 'invoice_type_text' => $row['default_invoice_type'] === 'special' ? '增值税专用发票' : '普通发票', 'title' => (string)$row['title'], 'tax_no' => (string)$row['tax_no'], 'bank_name' => (string)$row['bank_name'], 'bank_account' => (string)$row['bank_account'], 'reg_address' => (string)$row['reg_address'], 'reg_mobile' => (string)$row['reg_mobile'], 'email' => (string)$row['email'], 'receive_mobile' => (string)$row['receive_mobile'], 'is_default' => (int)$row['is_default'], 'can_special' => $hasSpecialInfo, 'time_text' => !empty($row['createtime']) ? date('Y-m-d H:i', (int)$row['createtime']) : '', ]; } /** * 格式化开票申请行输出(兼容旧版字段名与新版全量字段) * @param array $row * @return array */ public static function formatInvoiceRow(array $row) { $statusLabels = self::statusMap(); $titleType = (!empty($row['title_type']) && $row['title_type'] === 'enterprise') || !empty($row['tax_no']) ? 'enterprise' : 'personal'; $fileUrl = ''; $fileType = ''; if (!empty($row['invoice_file'])) { $rawFile = trim((string)$row['invoice_file']); $fileUrl = (strpos($rawFile, 'http') === 0) ? $rawFile : cdnurl($rawFile, true); $ext = strtolower((string)pathinfo(parse_url($fileUrl, PHP_URL_PATH) ?: $rawFile, PATHINFO_EXTENSION)); if (in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'], true)) { $fileType = 'image'; } elseif ($ext === 'ofd') { $fileType = 'ofd'; } else { $fileType = 'pdf'; } } $sn = !empty($row['invoice_sn']) ? (string)$row['invoice_sn'] : ('INV' . date('Ymd', (int)($row['createtime'] ?: time())) . str_pad((string)$row['id'], 6, '0', STR_PAD_LEFT)); $orderIds = self::parseIds($row['order_ids'] ?? ''); $orderCount = isset($row['order_count']) && (int)$row['order_count'] > 0 ? (int)$row['order_count'] : count($orderIds); return [ 'id' => (int)$row['id'], 'invoice_sn' => $sn, 'merchant_id' => (int)$row['merchant_id'], 'user_id' => (int)$row['user_id'], 'title_id' => isset($row['title_id']) ? (int)$row['title_id'] : 0, 'amount' => round((float)$row['amount'], 2), 'type' => (string)$row['type'], 'type_text' => $row['type'] === 'special' ? '专用发票' : '普通发票', 'type_full_text' => $row['type'] === 'special' ? '增值税专用发票' : '增值税普通发票', 'title_type' => $titleType, 'title_type_text' => $titleType === 'enterprise' ? '企业单位' : '个人/非企业', 'title' => (string)$row['title'], 'tax_no' => (string)$row['tax_no'], 'bank_name' => isset($row['bank_name']) ? (string)$row['bank_name'] : '', 'bank_account' => isset($row['bank_account']) ? (string)$row['bank_account'] : '', 'reg_address' => isset($row['reg_address']) ? (string)$row['reg_address'] : '', 'reg_mobile' => isset($row['reg_mobile']) ? (string)$row['reg_mobile'] : '', 'email' => (string)$row['email'], 'receive_mobile' => isset($row['receive_mobile']) ? (string)$row['receive_mobile'] : '', 'remark' => (string)$row['remark'], 'order_ids' => $orderIds, 'order_count' => $orderCount, 'status' => (string)$row['status'], 'status_text' => isset($statusLabels[$row['status']]) ? $statusLabels[$row['status']] : (string)$row['status'], 'invoice_code' => isset($row['invoice_code']) ? (string)$row['invoice_code'] : '', 'invoice_no' => (string)$row['invoice_no'], 'invoice_file' => $fileUrl, 'invoice_file_type' => $fileType, 'audit_msg' => (string)$row['audit_msg'], 'can_cancel' => $row['status'] === 'wait', 'time_text' => !empty($row['createtime']) ? date('Y-m-d H:i', (int)$row['createtime']) : '', 'issue_time_text' => !empty($row['issue_time']) ? date('Y-m-d H:i', (int)$row['issue_time']) : '', ]; } /** * 解析逗号分隔或数组形式的整数 ID 列表 * @param mixed $raw * @return int[] */ public static function parseIds($raw) { if (empty($raw)) { return []; } if (is_string($raw)) { $decodedStr = trim(htmlspecialchars_decode($raw)); if ($decodedStr !== '' && $decodedStr[0] === '[') { $jsonArr = json_decode($decodedStr, true); if (is_array($jsonArr)) { $raw = $jsonArr; } } } $list = is_array($raw) ? $raw : explode(',', (string)$raw); $ids = []; foreach ($list as $item) { if (is_array($item)) { continue; } foreach (explode(',', (string)$item) as $part) { $id = (int)trim($part, " \t\n\r\0\x0B\"'[]"); if ($id > 0 && !in_array($id, $ids, true)) { $ids[] = $id; } } } return $ids; } }