文件
yjyf/server/application/common/library/InvoiceService.php
MiAn fafddceaf2 feat: 新增发票抬头、晒单、钱包日志等模型及后台管理功能
- 新增 invoice_title、showcase、wallet_log 模型及对应语言包
- 新增后台控制器、视图及搜索字段配置
- 修复服务类目删除保护逻辑
- 优化后台登录页面文案及样式
- 新增前端角色切换逻辑及页面拦截
- 新增 SVG 图标及样式变量
- 修复接口权限及下拉框默认值问题
2026-11-11 17:02:35 +08:00

1219 行
51 KiB
PHP

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

<?php
namespace app\common\library;
use think\Db;
/**
* 开票与抬头管理领域服务
* 统一承载:主体识别 / 可开票额度核算 / 按单核销锁单 / 发票抬头管理 / 状态机流转
*/
class InvoiceService
{
/** 状态文案映射 */
public static function statusMap()
{
return [
'wait' => '开票中',
'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;
}
}