CRMEB/backend/app/services/order/OrderInvoiceServices.php
吴昊天 2463150b5f fix(发票): 统一普票/专票口径,详情支持申请开票与查看发票,列表状态对齐中台
专用发票默认关闭并受后台开关控制;旧订单详情接口改走中台。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-28 10:24:30 +08:00

564 lines
23 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
// +----------------------------------------------------------------------
// | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
// +----------------------------------------------------------------------
// | Copyright (c) 2016~2026 https://www.crmeb.com All rights reserved.
// +----------------------------------------------------------------------
// | Licensed CRMEB并不是自由软件,未经许可不能去掉CRMEB相关版权
// +----------------------------------------------------------------------
// | Author: CRMEB Team <admin@crmeb.com>
// +----------------------------------------------------------------------
declare (strict_types=1);
namespace app\services\order;
use app\jobs\OrderInvoiceJob;
use crmeb\basic\BaseService;
use crmeb\exceptions\AdminException;
use app\dao\order\OrderCenterDao;
use app\model\order\OrderCenter;
use crmeb\services\invoice\Invoice;
use app\dao\order\OrderInvoiceDao;
use app\services\user\UserInvoiceServices;
use app\services\user\UserServices;
use crmeb\trade\order\type\OrderTypeRegistry;
use think\facade\Log;
/**
* Class OrderInvoiceServices
* @package app\services\order
*/
class OrderInvoiceServices extends BaseService
{
/**
* @var OrderInvoiceDao
*/
protected $dao;
/**
* OrderInvoiceServices constructor.
* @param OrderInvoiceDao $dao
*/
public function __construct(OrderInvoiceDao $dao)
{
$this->dao = $dao;
}
public function chart(array $where)
{
$where['is_pay'] = 1;
$where['is_del'] = 0;
//全部
$data['all'] = (string)$this->dao->count($where);
//待开
$where['type'] = 1;
$data['noOpened'] = (string)$this->dao->count($where);
//已开
$where['type'] = 2;
$data['opened'] = (string)$this->dao->count($where);
//退款
$where['type'] = 3;
$data['refund'] = (string)$this->dao->count($where);
$data['elec_invoice'] = (int)sys_config('elec_invoice', 0);
return $data;
}
/**
* 后台获取开票列表
* @param array $where
* @return array
* @throws \think\db\exception\DataNotFoundException
* @throws \think\db\exception\DbException
* @throws \think\db\exception\ModelNotFoundException
*/
public function getList(array $where)
{
[$page, $list] = $this->getPageValue();
$field = 'id as invoice_id,order_id,header_type,type,name,duty_number,drawer_phone,email,tell,address,bank,card_number,is_invoice,invoice_number,remark as invoice_reamrk,invoice_time,add_time as invoice_add_time,unique_num,invoice_num,invoice_type,invoice_serial_number,red_invoice_num';
$where['is_pay'] = 1;
$where['is_del'] = 0;
// 订单中台关联查询:带 source_type/source_id,便于实时推导待发货/待收货
$list = $this->dao->getList($where, $field, ['order' => function ($query) {
$query->field('id,order_no,uid,pay_price,status_code,refund_status,extra_info,add_time,source_type,source_id');
}], 'add_time desc', $page, $list);
// 按源订单类型批量解析业务子状态(extra_info 快照会被 syncStatus 覆盖成 processing,不能直接用)
$businessStatusMap = $this->resolveBusinessStatusMap($list);
// 批量取下单用户,作为收货人姓名电话的兜底数据
$uids = array_filter(array_unique(array_column(array_column($list, 'order'), 'uid')));
$userList = $uids ? app()->make(UserServices::class)->getColumn([['uid', 'in', $uids]], 'uid,real_name,nickname,phone', 'uid') : [];
foreach ($list as &$item) {
$order = $item['order'] ?? [];
$extra = $order['extra_info'] ?? '';
$extra = is_string($extra) ? (json_decode($extra, true) ?: []) : ($extra ?: []);
$contact = $extra['contact'] ?? [];
$user = $userList[$order['uid']] ?? [];
$item['id'] = $order['id'] ?? 0;
// 前端契约:列表展示的订单号对应中台 order_no
$item['order_id'] = $order['order_no'] ?? '';
$item['pay_price'] = $order['pay_price'] ?? 0.00;
// 收货人姓名电话优先取订单快照,空串需回退下单用户资料(?? 只拦 null,拦不住空串)
$contactName = trim((string)($contact['name'] ?? ''));
$contactPhone = trim((string)($contact['phone'] ?? ''));
$item['real_name'] = $contactName !== '' ? $contactName : trim((string)($user['real_name'] ?? ($user['nickname'] ?? '')));
$item['user_phone'] = $contactPhone !== '' ? $contactPhone : trim((string)($user['phone'] ?? ''));
$statusCode = (string)($order['status_code'] ?? '');
$item['status_code'] = $statusCode;
$centerId = (int)($order['id'] ?? 0);
$businessStatus = $businessStatusMap[$centerId] ?? null;
if (is_array($businessStatus)) {
if (empty($businessStatus['label']) && !empty($businessStatus['text'])) {
$businessStatus['label'] = $businessStatus['text'];
}
$item['business_status'] = $businessStatus;
// 列表唯一展示文案:优先业务子状态(待收货),否则中台统一状态
$item['status_text'] = (string)($businessStatus['label'] ?: ($businessStatus['text'] ?? ''));
} else {
$item['business_status'] = null;
$item['status_text'] = '';
}
if ($item['status_text'] === '') {
// 管理端与订单中台列表一致:processing 展示「待处理」
$item['status_text'] = $statusCode === OrderCenter::STATUS_PROCESSING
? '待处理'
: (OrderCenter::$statusMap[$statusCode] ?? ($statusCode !== '' ? $statusCode : '--'));
}
// 兼容旧前端/移动端数字状态
$item['status'] = $this->statusToLegacy($statusCode);
$item['refund_status'] = $order['refund_status'] ?? 0;
$item['add_time'] = date('Y-m-d H:i:s', $order['add_time'] ?? $item['invoice_add_time'] ?? time());
$item['invoice_add_time'] = date('Y-m-d H:i:s', $item['invoice_add_time']);
// 中台关联原始数据不再下发给前端
unset($item['order']);
}
unset($item);
$count = $this->dao->count($where);
return compact('list', 'count');
}
/**
* 批量从源订单适配器推导业务子状态
* @param array $list 含 order 关联的发票列表
* @return array<int, array|null> order_center.id => business_status
*/
protected function resolveBusinessStatusMap(array $list): array
{
$grouped = [];
$centerIndex = [];
foreach ($list as $row) {
$order = $row['order'] ?? [];
$centerId = (int)($order['id'] ?? 0);
$sourceType = (string)($order['source_type'] ?? '');
$sourceId = (int)($order['source_id'] ?? 0);
if ($centerId <= 0 || $sourceType === '' || $sourceId <= 0) {
continue;
}
$grouped[$sourceType][$sourceId] = true;
$centerIndex[$sourceType][$sourceId][] = $centerId;
}
$result = [];
foreach ($grouped as $sourceType => $idMap) {
try {
$adapter = OrderTypeRegistry::getAdapter($sourceType);
$modelClass = $adapter::getModelClass();
/** @var \crmeb\basic\BaseModel $model */
$model = app()->make($modelClass);
$sourceRows = $model->whereIn('id', array_keys($idMap))->select()->toArray();
foreach ($sourceRows as $sourceOrder) {
$sourceId = (int)($sourceOrder['id'] ?? 0);
$businessStatus = $adapter->resolveBusinessStatus($sourceOrder);
foreach ($centerIndex[$sourceType][$sourceId] ?? [] as $centerId) {
$result[$centerId] = $businessStatus;
}
}
} catch (\Throwable $e) {
Log::warning('发票列表推导订单业务状态失败: ' . $e->getMessage(), ['source_type' => $sourceType]);
}
}
return $result;
}
/**
* 订单中台状态码映射为旧版订单状态数值(兼容移动端发票列表 _type)
* @param string $statusCode
* @return int
*/
public function statusToLegacy(string $statusCode): int
{
return match ($statusCode) {
OrderCenter::STATUS_PENDING_PAYMENT => 0,
OrderCenter::STATUS_PROCESSING => 0,
OrderCenter::STATUS_PENDING_REVIEW => 2,
OrderCenter::STATUS_COMPLETED => 3,
OrderCenter::STATUS_CANCELED => -1,
OrderCenter::STATUS_AFTER_SALE, OrderCenter::STATUS_REFUNDING => -3,
OrderCenter::STATUS_REFUNDED => -2,
OrderCenter::STATUS_DELETED => -4,
default => 0,
};
}
/**
* 前端获取开票列表(带商品信息)
* @param $where
* @return array
*/
public function getOrderInvoiceList(array $where)
{
[$page, $list] = $this->getPageValue();
$where['is_pay'] = 1;
$where['is_del'] = 0;
$list = $this->dao->getList($where, '*', ['order' => function ($query) {
$query->field('id,order_no,pay_price,extra_info');
}], 'add_time desc', $page, $list);
foreach ($list as &$item) {
$order = $item['order'] ?? [];
$extra = $order['extra_info'] ?? '';
$extra = is_string($extra) ? (json_decode($extra, true) ?: []) : ($extra ?: []);
$order['cartInfo'] = $this->buildLegacyCartInfo($extra);
$order['pay_price'] = $order['pay_price'] ?? '0.00';
// 移动端跳转开票详情携带的订单号对应中台 order_no
$order['order_id'] = $order['order_no'] ?? '';
unset($order['extra_info']);
$item['order'] = $order;
}
unset($item);
return $list;
}
/**
* 由订单中台商品快照合成旧版 cartInfo 结构(兼容移动端旧组件)
*
* 移动端 orderGoods 组件按旧版结构取值:attrInfo 存在时图片/规格价取
* attrInfo.image / attrInfo.price,商品单价取 productInfo.price,
* 三者缺一会导致商品图片或金额不回显。
*
* @param array $extra 解析后的 extra_info
* @return array
*/
public function buildLegacyCartInfo(array $extra): array
{
$cartInfo = [];
foreach ($extra['items'] ?? [] as $product) {
$attrInfo = is_array($product['attr_info'] ?? null) ? $product['attr_info'] : [];
// 规格图优先,缺省回退商品主图;规格价优先,缺省回退商品价
$image = $attrInfo['image'] ?? ($product['image'] ?? '');
$price = $attrInfo['price'] ?? ($product['price'] ?? 0);
$cartInfo[] = [
'cart_num' => $product['cart_num'] ?? 1,
'truePrice' => $product['true_price'] ?? 0,
// 开票仅针对已支付订单,商品按有效态展示(is_valid 缺省会被组件置灰半透明)
'is_valid' => true,
'productInfo' => [
'image' => $product['image'] ?? '',
'store_name' => $product['store_name'] ?? '',
'price' => $price,
'attrInfo' => array_merge($attrInfo, [
'suk' => $attrInfo['suk'] ?? ($product['sku'] ?? ''),
'image' => $image,
'price' => $price,
]),
],
];
}
return $cartInfo;
}
/**
* 订单申请开票
* @param int $uid
* @param $order_id
* @param int $invoice_id
* @return mixed
* @throws \think\db\exception\DataNotFoundException
* @throws \think\db\exception\DbException
* @throws \think\db\exception\ModelNotFoundException
*/
public function makeUp(int $uid, $order_id, int $invoice_id)
{
if (!$order_id) throw new AdminException('参数错误');
if (!$invoice_id) throw new AdminException('请选择发票');
/** @var OrderCenterDao $orderCenterServices */
$orderCenterServices = app()->make(OrderCenterDao::class);
/** @var UserInvoiceServices $userInvoiceServices */
$userInvoiceServices = app()->make(UserInvoiceServices::class);
// 订单中台以 order_no 为全局单号,兼容传订单号或中台ID
$order = $orderCenterServices->getOne(['order_no|id' => $order_id, 'is_del' => 0]);
if (!$order) {
throw new AdminException('订单不存在');
}
// 校验订单归属,防止为他人订单申请开票
if ((int)$order['uid'] !== $uid) {
throw new AdminException('订单不存在');
}
//检测再带查询
$invoice = $userInvoiceServices->checkInvoice($invoice_id, $uid);
if ($this->dao->getOne(['order_id' => $order['id'], 'uid' => $uid])) {
throw new AdminException('发票已申请');
}
if ($order['refund_status'] == 2) {
throw new AdminException('订单已退款');
}
if ($order['refund_status'] == 1) {
throw new AdminException('正在申请退款中');
}
// 仅已支付订单可申请开票,未支付订单的开票记录不在列表中展示
if ($order['pay_status'] == 0) {
throw new AdminException('订单未支付,无法申请开票');
}
// 显式构建申请数据,避免抬头表多余字段(is_default 等)写入开票表
$data = [];
$data['uid'] = $uid;
$data['category'] = 'order';
$data['order_id'] = (int)$order['id'];
$data['invoice_id'] = $invoice_id;
$data['header_type'] = $invoice['header_type'];
$data['type'] = $invoice['type'];
$data['name'] = $invoice['name'];
$data['duty_number'] = $invoice['duty_number'];
$data['drawer_phone'] = $invoice['drawer_phone'];
$data['email'] = $invoice['email'];
$data['tell'] = $invoice['tell'];
$data['address'] = $invoice['address'];
$data['bank'] = $invoice['bank'];
$data['card_number'] = $invoice['card_number'];
$data['is_pay'] = $order['pay_status'] == 1 ? 1 : 0;
$data['add_time'] = time();
if (!$re = $this->dao->save($data)) {
throw new AdminException('申请失败');
}
if (sys_config('elec_invoice', 1) == 1 && sys_config('auto_invoice', 1) == 1 && $data['is_pay'] == 1) {
//自动开票
OrderInvoiceJob::dispatchSecs(10, 'autoInvoice', [$re->id]);
}
return ['id' => $re->id];
}
/**
* 下单勾选发票时创建开票申请记录
* 列表/支付回写以 eb_order_invoice 为准;中台 order_center.invoice 只作快照展示
* @param int $uid 用户uid
* @param int $orderCenterId 订单中台主键
* @param array $invoice 用户发票抬头(eb_user_invoice)
* @param int $isPay 是否已支付 0/1
* @return int|null 开票记录id
*/
public function createFromOrder(int $uid, int $orderCenterId, array $invoice, int $isPay = 0): ?int
{
if (!$orderCenterId || empty($invoice)) {
return null;
}
// 同一订单已有申请则跳过,避免重复写入
if ($this->dao->getOne(['order_id' => $orderCenterId, 'is_del' => 0])) {
return null;
}
$data = [];
$data['uid'] = $uid;
$data['category'] = 'order';
$data['order_id'] = $orderCenterId;
$data['invoice_id'] = (int)($invoice['id'] ?? 0);
$data['header_type'] = $invoice['header_type'] ?? 1;
$data['type'] = $invoice['type'] ?? 1;
$data['name'] = $invoice['name'] ?? '';
$data['duty_number'] = $invoice['duty_number'] ?? '';
$data['drawer_phone'] = $invoice['drawer_phone'] ?? '';
$data['email'] = $invoice['email'] ?? '';
$data['tell'] = $invoice['tell'] ?? '';
$data['address'] = $invoice['address'] ?? '';
$data['bank'] = $invoice['bank'] ?? '';
$data['card_number'] = $invoice['card_number'] ?? '';
$data['is_pay'] = $isPay;
$data['add_time'] = time();
if (!$re = $this->dao->save($data)) {
return null;
}
return (int)$re->id;
}
public function setInvoice(int $id, array $data)
{
$orderInvoice = $this->dao->get($id);
if (!$orderInvoice) {
throw new AdminException('数据不存在');
}
if ($data['is_invoice'] == 1) {
$data['invoice_time'] = time();
}
if (!$this->dao->update($id, $data, 'id')) {
throw new AdminException('设置失败');
}
return true;
}
/**
* 开具发票
* @param $id
* @return bool
* @throws \ReflectionException
* @throws \think\db\exception\DataNotFoundException
* @throws \think\db\exception\DbException
* @throws \think\db\exception\ModelNotFoundException
* @author wuhaotian
* @email 442384644@qq.com
* @date 2024/5/15
*/
public function invoiceIssuance($id)
{
if (sys_config('elec_invoice', 1) != 1) {
throw new AdminException('电子发票功能未开启,请在一号通中开启并且在商城后台一号通配置中开启');
}
$info = $this->dao->getOne(['id' => $id]);
if (!$info) {
throw new AdminException('发票不存在');
}
/** @var OrderCenterDao $orderDao */
$orderDao = app()->make(OrderCenterDao::class);
$orderInfo = $orderDao->get($info['order_id']);
if (!$orderInfo) {
throw new AdminException('订单不存在');
}
// 从订单中台 extra_info.items 读取商品快照(旧 eb_store_order_cart_info 已废弃)
$extra = $orderInfo['extra_info'] ?? null;
$extra = is_string($extra) ? json_decode($extra, true) : ($extra ?? []);
$cartInfo = $extra['items'] ?? [];
$data = [];
$data['is_tax_inclusive'] = 1;
$data['account_name'] = $info['name'];
$data['bank_name'] = '';
$data['bank_account'] = '';
$data['telephone'] = $info['drawer_phone'];
$data['company_address'] = '';
$data['drawer'] = '';
$data['amount'] = $orderInfo['pay_price'];
if ($info['header_type'] == 1) {
$data['invoice_type'] = 82;
$data['tax_id'] = '';
$data['is_enterprise'] = 0;
} else {
$data['invoice_type'] = $info['type'] == 1 ? 82 : 81;
$data['tax_id'] = $info['duty_number'];
$data['is_enterprise'] = 1;
}
$goods = [];
foreach ($cartInfo as $item) {
$goods[] = [
'cate_id' => sys_config('elec_invoice_cate', 1799),
'store_name' => $item['store_name'] ?? '',
'unit_price' => bcadd((string)($item['true_price'] ?? '0.00'), (string)($item['postage_price'] ?? '0.00'), 2),
'num' => $item['cart_num'] ?? 1,
'tax_rate' => sys_config('elec_invoice_tax_rate', 13),
];
}
$data['goods'] = $goods;
// 一号通开票的唯一标识使用中台订单号 order_no
try {
$invoice = app()->make(Invoice::class, [yihaotong_config()]);
$res = $invoice->invoiceIssuance($orderInfo['order_no'], $data);
if ($res['status'] == 200) {
$this->dao->update($id, [
'is_invoice' => 1,
'unique_num' => $orderInfo['order_no'],
'invoice_num' => $res['data']['invoice_num'],
'invoice_type' => $res['data']['invoice_type'],
'invoice_serial_number' => $res['data']['invoice_serial_number'],
'invoice_time' => time()
]);
}
} catch (\Exception $e) {
Log::error('自动开具发票失败,失败原因:' . $e->getMessage());
}
return true;
}
/**
* 未开发票自动开具电子发票
* @return bool
* @author wuhaotian
* @email 442384644@qq.com
* @date 2024/5/15
*/
public function autoInvoice()
{
if (sys_config('elec_invoice', 1) != 1) {
return true;
}
if (sys_config('auto_invoice', 1) == 1) {
$list = $this->dao->getColumn([
['is_pay', '=', 1],
['is_invoice', '=', 1],
['unique_num', '=', ''],
['is_del', '=', 0],
['add_time', '<', time() - 60],
], 'id');
if ($list) {
foreach ($list as $item) {
//自动开票
OrderInvoiceJob::dispatchSecs(10, 'autoInvoice', [$item]);
}
}
}
return true;
}
/**
* 退款订单自动冲红
* @return bool
* @author wuhaotian
* @email 442384644@qq.com
* @date 2024/5/15
*/
public function autoInvoiceRed()
{
if (sys_config('elec_invoice', 1) != 1) {
return true;
}
if (sys_config('auto_invoice', 1) == 1) {
$list = $this->dao->getColumn([
['is_pay', '=', 1],
['is_invoice', '=', 1],
['unique_num', '<>', ''],
['red_invoice_num', '=', ''],
['is_del', '=', 1],
['add_time', '<', time() - 60],
], 'id');
if ($list) {
foreach ($list as $item) {
//自动冲红
OrderInvoiceJob::dispatchSecs(10, 'autoInvoiceRed', [$item]);
}
}
}
return true;
}
/**
* 负数发票开具
* @param $id
* @return bool
* @throws \think\db\exception\DataNotFoundException
* @throws \think\db\exception\DbException
* @throws \think\db\exception\ModelNotFoundException
* @author wuhaotian
* @email 442384644@qq.com
* @date 2024/5/16
*/
public function redInvoiceIssuance($id)
{
$invoiceInfo = $this->dao->get($id);
if (!$invoiceInfo) throw new AdminException('发票不存在');
if ($invoiceInfo['is_pay'] == 0 || $invoiceInfo['is_invoice'] == 0 || $invoiceInfo['unique_num'] === '') {
throw new AdminException('发票状态有误,请检查');
}
$invoice = app()->make(Invoice::class, [yihaotong_config()]);
// 驱动契约要求 camelCase 键名 invoiceNum
$res = $invoice->redInvoiceIssuance(['invoiceNum' => $invoiceInfo['invoice_num'], 'apply_type' => '01']);
if ($res['status'] != 200) throw new AdminException('开具负数发票失败,请检查');
$this->dao->update($id, ['red_invoice_num' => 1]);
return true;
}
}