feat: 新增发票抬头、晒单、钱包日志等模型及后台管理功能

- 新增 invoice_title、showcase、wallet_log 模型及对应语言包
- 新增后台控制器、视图及搜索字段配置
- 修复服务类目删除保护逻辑
- 优化后台登录页面文案及样式
- 新增前端角色切换逻辑及页面拦截
- 新增 SVG 图标及样式变量
- 修复接口权限及下拉框默认值问题
这个提交包含在:
2026-11-11 17:02:35 +08:00
父节点 5908cf6b16
当前提交 fafddceaf2
共有 332 个文件被更改,包括 33892 次插入 和 8835 次删除

查看文件

@@ -2,6 +2,7 @@
namespace app\api\controller;
use app\common\library\InvoiceService;
use app\common\library\OrderStatus;
use think\Db;
@@ -85,35 +86,44 @@ class Merchant extends Base
$uids = $this->merchantUserIds($merchant);
$isOwner = (int)$merchant['user_id'] === $this->uid();
$orderCount = (int)Db::name('order')->where('user_id', 'in', $uids)->where('pay_status', 1)->count();
$totalAmount = (float)Db::name('order')->where('user_id', 'in', $uids)->where('pay_status', 1)->sum('pay_amount');
$invoiced = (float)Db::name('invoice')->where('merchant_id', $merchant['id'])->where('status', 'issued')->sum('amount');
$orderCount = (int)Db::name('order')->where('user_id', 'in', $uids)->where('pay_status', 1)->where('status', '<>', OrderStatus::CANCELLED)->count();
$quota = InvoiceService::getQuotaSummary($this->uid());
$totalAmount = (float)$quota['paid_amount'];
$invoiced = (float)$quota['used_amount'];
$this->success('', [
'applied' => true,
'is_owner' => $isOwner,
'role' => $isOwner ? 'owner' : 'member',
'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'],
'role' => $isOwner ? 'owner' : 'member',
'is_owner' => $isOwner,
'owner_user_id' => (int)$merchant['user_id'],
'member_count' => max(0, count($uids) - 1),
'order_count' => $orderCount,
'total_amount' => round($totalAmount, 2),
'invoiced_amount' => round($invoiced, 2),
'invoiceable' => round(max(0, $totalAmount - $invoiced), 2),
'id' => (int)$merchant['id'],
'name' => $merchant['name'],
'license_no' => $merchant['license_no'],
'legal_person' => $merchant['legal_person'],
'contact' => $merchant['contact'],
'mobile' => $merchant['mobile'],
'province' => (string)$merchant['province'],
'city' => (string)$merchant['city'],
'district' => (string)$merchant['district'],
'detail_address' => (string)$merchant['address'],
'address' => $merchant['province'] . $merchant['city'] . $merchant['district'] . $merchant['address'],
'invoice_title' => $merchant['invoice_title'],
'invoice_tax_no' => $merchant['invoice_tax_no'],
'invoice_bank' => $merchant['invoice_bank'],
'invoice_account' => $merchant['invoice_account'],
'settle_type' => $merchant['settle_type'],
'verify_status' => $merchant['verify_status'],
'audit_msg' => $merchant['audit_msg'],
'role' => $isOwner ? 'owner' : 'member',
'is_owner' => $isOwner,
'owner_user_id' => (int)$merchant['user_id'],
'member_count' => max(0, count($uids) - 1),
'order_count' => $orderCount,
'total_amount' => round($totalAmount, 2),
'invoiced_amount' => round($invoiced, 2),
'issued_amount' => round((float)$quota['issued_amount'], 2),
'wait_invoice_amount' => round((float)$quota['wait_amount'], 2),
'invoiceable' => round((float)$quota['available'], 2),
],
]);
}
@@ -139,6 +149,23 @@ class Merchant extends Base
$this->error('联系电话格式不正确');
}
Db::name('merchant')->where('id', $merchant['id'])->update($data);
// 若维护了商家开票资料,同步刷新关联的商家默认发票抬头
if (isset($data['invoice_title']) || isset($data['invoice_tax_no']) || isset($data['invoice_bank']) || isset($data['invoice_account'])) {
$latest = Db::name('merchant')->where('id', $merchant['id'])->find();
if ($latest) {
$syncFields = ['updatetime' => time()];
if (!empty($latest['invoice_title'])) $syncFields['title'] = $latest['invoice_title'];
if (!empty($latest['invoice_tax_no'])) $syncFields['tax_no'] = strtoupper($latest['invoice_tax_no']);
if (isset($data['invoice_bank'])) $syncFields['bank_name'] = $latest['invoice_bank'];
if (isset($data['invoice_account'])) $syncFields['bank_account'] = $latest['invoice_account'];
Db::name('invoice_title')
->where('merchant_id', (int)$merchant['id'])
->where('title_type', 'enterprise')
->update($syncFields);
}
}
$this->success('已保存');
}
@@ -181,11 +208,22 @@ class Merchant extends Base
$uids = $this->merchantUserIds($merchant);
$status = $this->request->param('status', 'all');
$keyword = trim((string)$this->request->param('keyword', ''));
$memberUid = (int)$this->request->param('member_uid', 0);
$month = trim((string)$this->request->param('month', ''));
$page = max(1, (int)$this->request->param('page', 1));
$limit = min(50, max(1, (int)$this->request->param('limit', 20)));
$build = function () use ($uids, $status, $keyword) {
$q = Db::name('order')->where('user_id', 'in', $uids);
$targetUids = ($memberUid > 0 && in_array($memberUid, $uids, true)) ? [$memberUid] : $uids;
$build = function () use ($targetUids, $status, $keyword, $month) {
$q = Db::name('order')->where('user_id', 'in', $targetUids);
if ($month !== '' && preg_match('/^\d{4}-\d{2}$/', $month)) {
$startTs = strtotime($month . '-01 00:00:00');
$endTs = strtotime('+1 month', $startTs) - 1;
if ($startTs > 0 && $endTs > 0) {
$q->where('createtime', 'between', [$startTs, $endTs]);
}
}
if ($keyword !== '') {
$q->where(function ($sub) use ($keyword) {
$sub->where('contact', 'like', "%{$keyword}%")
@@ -211,6 +249,10 @@ class Merchant extends Base
$total = $build()->count();
$rows = $build()->order('id desc')->page($page, $limit)->select();
$userMap = Db::name('user')->where('id', 'in', $uids)->column('id,nickname,mobile', 'id');
$serviceIds = array_values(array_unique(array_filter(array_map('intval', array_column($rows, 'service_id')))));
$serviceImgMap = $serviceIds ? Db::name('service')->where('id', 'in', $serviceIds)->column('image', 'id') : [];
$orderIds = array_values(array_unique(array_filter(array_map('intval', array_column($rows, 'id')))));
$batchMap = $orderIds ? Db::name('merchant_order')->where('order_id', 'in', $orderIds)->column('batch_no', 'order_id') : [];
foreach ($rows as &$row) {
$ownerUser = (int)$row['user_id'] === (int)$merchant['user_id'];
@@ -227,10 +269,10 @@ class Merchant extends Base
$row['account_name'] = $u && $u['nickname'] ? $u['nickname'] : ('用户' . substr($mobile, -4));
$row['account_role'] = $ownerUser ? 'owner' : 'member';
$row['account_role_text'] = $ownerUser ? '主账号' : '关联账号';
$row['batch_no'] = isset($batchMap[(int)$row['id']]) ? (string)$batchMap[(int)$row['id']] : '';
$img = !empty($row['service_image']) ? $row['service_image'] : '';
if (empty($img) && !empty($row['service_id'])) {
$s = Db::name('service')->where('id', (int)$row['service_id'])->find();
$img = $s ? $s['image'] : '';
if (empty($img) && !empty($row['service_id']) && isset($serviceImgMap[(int)$row['service_id']])) {
$img = $serviceImgMap[(int)$row['service_id']];
}
if ($img && strpos($img, 'http') !== 0) {
$img = cdnurl($img, true);
@@ -241,171 +283,63 @@ class Merchant extends Base
unset($row);
$this->success('', [
'list' => $rows,
'total' => (int)$total,
'page' => $page,
'list' => $rows,
'total' => (int)$total,
'page' => $page,
'has_more' => ($page * $limit) < (int)$total,
]);
}
/**
* 开票记录与可开票额度(个人用户与认证商家通用)
* 开票记录与可开票额度(个人用户与认证商家通用,兼容旧版调用并复用 InvoiceService)
*/
public function invoices()
{
$merchant = $this->merchantInfo();
$isPassMerchant = ($merchant && $merchant['verify_status'] === 'pass');
if ($isPassMerchant) {
$uids = $this->merchantUserIds($merchant);
$mid = (int)$merchant['id'];
$list = Db::name('invoice')
->where(function ($q) use ($mid, $uids) {
$q->where('merchant_id', $mid)->whereOr('user_id', 'in', $uids);
})
->order('id desc')
->limit(30)
->select();
$paid = (float)Db::name('order')->where('user_id', 'in', $uids)->where('pay_status', 1)->sum('pay_amount');
$used = (float)Db::name('invoice')
->where(function ($q) use ($mid, $uids) {
$q->where('merchant_id', $mid)->whereOr('user_id', 'in', $uids);
})
->where('status', 'in', ['wait', 'issued'])
->sum('amount');
$issued = (float)Db::name('invoice')
->where(function ($q) use ($mid, $uids) {
$q->where('merchant_id', $mid)->whereOr('user_id', 'in', $uids);
})
->where('status', 'issued')
->sum('amount');
} else {
$uid = $this->uid();
$list = Db::name('invoice')->where('user_id', $uid)->order('id desc')->limit(30)->select();
$paid = (float)Db::name('order')->where('user_id', $uid)->where('pay_status', 1)->sum('pay_amount');
$used = (float)Db::name('invoice')->where('user_id', $uid)->where('status', 'in', ['wait', 'issued'])->sum('amount');
$issued = (float)Db::name('invoice')->where('user_id', $uid)->where('status', 'issued')->sum('amount');
}
$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['title_type'] = !empty($row['tax_no']) ? 'enterprise' : 'personal';
$row['title_type_text'] = !empty($row['tax_no']) ? '企业单位' : '个人/非企业';
$row['time_text'] = date('Y-m-d H:i', (int)$row['createtime']);
}
unset($row);
$userRow = Db::name('user')->where('id', $this->uid())->field('nickname,mobile')->find();
$personalDefaultTitle = ($userRow && !empty($userRow['nickname'])) ? (string)$userRow['nickname'] : '个人';
$records = InvoiceService::getRecordList($this->uid(), 'all', 1, 30);
$quota = InvoiceService::getQuotaSummary($this->uid());
$this->success('', [
'list' => $list,
'quota' => [
'available' => round(max(0, $paid - $used), 2),
'paid_amount' => round($paid, 2),
'used_amount' => round($used, 2),
'issued_amount' => round($issued, 2),
'is_merchant' => $isPassMerchant,
'default_title_type' => $isPassMerchant ? 'enterprise' : 'personal',
'default_title' => $isPassMerchant ? ($merchant['invoice_title'] ?: $merchant['name']) : $personalDefaultTitle,
'default_tax_no' => $isPassMerchant ? (string)$merchant['invoice_tax_no'] : '',
'enterprise_title' => $merchant ? (string)($merchant['invoice_title'] ?: $merchant['name']) : '',
'enterprise_tax_no' => $merchant ? (string)$merchant['invoice_tax_no'] : '',
'personal_title' => $personalDefaultTitle,
],
'list' => $records['list'],
'quota' => $quota,
]);
}
/**
* 申请开票(支持个人用户与认证商家)
* 申请开票(向下兼容旧版入口,底层复用 InvoiceService)
* @ApiMethod (POST)
*/
public function invoice()
{
$merchant = $this->merchantInfo();
$isPassMerchant = ($merchant && $merchant['verify_status'] === 'pass');
$params = [
'order_ids' => $this->request->post('order_ids', '', null),
'amount' => $this->request->post('amount', 0),
'title_id' => $this->request->post('title_id', 0),
'type' => $this->request->post('type', 'normal'),
'title_type' => $this->request->post('title_type', ''),
'title' => $this->request->post('title', ''),
'tax_no' => $this->request->post('tax_no', ''),
'bank_name' => $this->request->post('bank_name', ''),
'bank_account' => $this->request->post('bank_account', ''),
'reg_address' => $this->request->post('reg_address', ''),
'reg_mobile' => $this->request->post('reg_mobile', ''),
'email' => $this->request->post('email', ''),
'receive_mobile' => $this->request->post('receive_mobile', ''),
'remark' => $this->request->post('remark', ''),
'save_title' => $this->request->post('save_title', 0),
];
$amount = round((float)$this->request->post('amount'), 2);
$type = $this->request->post('type', 'normal');
$titleType = trim((string)$this->request->post('title_type', ''));
$title = trim((string)$this->request->post('title', ''));
$taxNo = strtoupper(trim((string)$this->request->post('tax_no', '')));
$email = trim((string)$this->request->post('email', ''));
$remark = trim((string)$this->request->post('remark', ''));
if ($amount <= 0) {
$this->error('请填写开票金额');
}
if (!in_array($type, ['normal', 'special'], true)) {
$this->error('发票类型错误');
try {
$result = InvoiceService::submitInvoice($this->uid(), $params, false);
} catch (\InvalidArgumentException $e) {
$this->error($e->getMessage());
return;
} catch (\Exception $e) {
\think\Log::error('[Merchant::invoice] ' . $e->getMessage());
$this->error('开票申请提交失败,请稍后重试');
return;
}
if (!in_array($titleType, ['personal', 'enterprise'], true)) {
$titleType = ($isPassMerchant || $taxNo !== '' || $type === 'special') ? 'enterprise' : 'personal';
}
if ($titleType === 'personal') {
if ($type === 'special') {
$this->error('个人抬头仅支持开具普通发票');
}
if ($title === '') {
$userRow = Db::name('user')->where('id', $this->uid())->field('nickname')->find();
$title = ($userRow && !empty($userRow['nickname'])) ? (string)$userRow['nickname'] : '个人';
}
$taxNo = '';
} else {
if ($title === '' && $isPassMerchant) {
$title = (string)($merchant['invoice_title'] ?: $merchant['name']);
}
if ($taxNo === '' && $isPassMerchant) {
$taxNo = strtoupper(trim((string)$merchant['invoice_tax_no']));
}
if ($title === '') {
$this->error('请填写企业发票抬头');
}
if ($taxNo === '') {
$this->error('请填写纳税人识别号(税号)');
}
}
if ($isPassMerchant) {
$uids = $this->merchantUserIds($merchant);
$mid = (int)$merchant['id'];
$paid = (float)Db::name('order')->where('user_id', 'in', $uids)->where('pay_status', 1)->sum('pay_amount');
$used = (float)Db::name('invoice')
->where(function ($q) use ($mid, $uids) {
$q->where('merchant_id', $mid)->whereOr('user_id', 'in', $uids);
})
->where('status', 'in', ['wait', 'issued'])
->sum('amount');
} else {
$uid = $this->uid();
$paid = (float)Db::name('order')->where('user_id', $uid)->where('pay_status', 1)->sum('pay_amount');
$used = (float)Db::name('invoice')->where('user_id', $uid)->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' => $isPassMerchant ? (int)$merchant['id'] : 0,
'user_id' => $this->uid(),
'amount' => $amount,
'type' => $type,
'title' => $title,
'tax_no' => $taxNo,
'email' => $email,
'remark' => $remark,
'status' => 'wait',
'createtime' => time(),
'updatetime' => time(),
]);
$this->success('开票申请已提交,3 个工作日内开具');
$this->success('开票申请已提交,3 个工作日内开具', $result);
}
/**
@@ -503,7 +437,9 @@ class Merchant extends Base
try {
$this->notify('user', (int)$user['id'], '已加入商家账号', '您的账号已关联到商家「' . $merchant['name'] . '」,下单、账单、开票数据将实时同步', 'system', 0);
} catch (\Exception $e) {}
} catch (\Exception $e) {
\think\Log::error('[Merchant::bindMember notify] ' . $e->getMessage());
}
$this->success('绑定成功,关联账号数据将实时同步', [
'member_id' => (int)$id,
@@ -542,7 +478,9 @@ class Merchant extends Base
Db::name('merchant_member')->where('id', (int)$row['id'])->delete();
try {
$this->notify('user', (int)$row['user_id'], '商家关联已解除', '您已与商家「' . $merchant['name'] . '」解除关联,对方商家数据不再同步', 'system', 0);
} catch (\Exception $e) {}
} catch (\Exception $e) {
\think\Log::error('[Merchant::unbindMember notify] ' . $e->getMessage());
}
$this->success('已解除关联,对方将不再同步商家数据');
}
@@ -604,94 +542,406 @@ class Merchant extends Base
public function importBatch()
{
$merchant = $this->requireMerchant();
$items = $this->request->post('items/a', []);
if (empty($items)) {
$items = $this->request->post('orders/a', []);
$rawOrders = $this->request->post('orders', null, null);
if ($rawOrders === null || $rawOrders === '' || $rawOrders === []) {
$rawOrders = $this->request->post('items', [], null);
}
$items = [];
if (is_string($rawOrders)) {
$decoded = json_decode(htmlspecialchars_decode(trim($rawOrders)), true);
if (is_array($decoded)) {
$items = $decoded;
}
} elseif (is_array($rawOrders)) {
if (count($rawOrders) === 1 && isset($rawOrders[0]) && is_string($rawOrders[0])) {
$decoded = json_decode(htmlspecialchars_decode(trim($rawOrders[0])), true);
$items = is_array($decoded) ? $decoded : $rawOrders;
} else {
$items = $rawOrders;
}
}
if (empty($items) || !is_array($items)) {
$this->error('导入数据不能为空');
}
if (count($items) > 100) {
$this->error('单次批量导入最多支持 100 条订单');
}
$globalServiceId = (int)$this->request->post('service_id', 1);
$svc = Db::name('service')->where('id', $globalServiceId)->find();
$defaultServiceName = $svc ? $svc['name'] : '家居安装';
$defaultPrice = $svc && (float)$svc['base_price'] > 0 ? (float)$svc['base_price'] : 80.00;
$globalServiceId = (int)$this->request->post('service_id', 0);
$serviceCache = [];
$preparedRows = [];
foreach ($items as $idx => $item) {
if (is_string($item)) {
$decItem = json_decode(htmlspecialchars_decode(trim($item)), true);
if (is_array($decItem)) {
$item = $decItem;
}
}
if (!is_array($item)) {
continue;
}
$contact = trim((string)(isset($item['contact']) ? $item['contact'] : '买家'));
$mobile = preg_replace('/[^\d]/', '', trim((string)(isset($item['mobile']) ? $item['mobile'] : '')));
$address = trim((string)(isset($item['address']) ? $item['address'] : ''));
if ($mobile === '' && $address === '') {
continue;
}
$rowNo = $idx + 1;
if (!preg_match('/^1\d{10}$/', $mobile)) {
$this->error("第 {$rowNo} 条订单联系电话格式不正确");
}
if ($address === '') {
$this->error("第 {$rowNo} 条订单缺少详细服务地址");
}
$itemServiceId = !empty($item['service_id']) ? (int)$item['service_id'] : $globalServiceId;
if ($itemServiceId <= 0) {
$this->error("第 {$rowNo} 条订单未指定有效的服务项目");
}
if (!array_key_exists($itemServiceId, $serviceCache)) {
$serviceCache[$itemServiceId] = Db::name('service')
->where('id', $itemServiceId)
->where('status', 'normal')
->find() ?: null;
}
$svc = $serviceCache[$itemServiceId];
if (!$svc) {
$this->error("第 {$rowNo} 条订单关联的服务项目不存在或已下架");
}
if ($svc['price_type'] === 'quote') {
$this->error("第 {$rowNo} 条订单所选服务「{$svc['name']}」为师傅报价项目,不支持批量一口价导入");
}
$goodsNum = max(1, (int)(isset($item['goods_num']) ? $item['goods_num'] : (isset($item['num']) ? $item['num'] : 1)));
$floor = isset($item['floor']) ? max(1, (int)$item['floor']) : 1;
$hasElevator = isset($item['has_elevator'])
? (int)$item['has_elevator']
: (isset($item['elevator']) ? (int)$item['elevator'] : 1);
$urgent = !empty($item['urgent']) ? 1 : 0;
$specId = isset($item['spec_id']) ? (int)$item['spec_id'] : 0;
$reqServiceType = isset($item['service_type']) ? trim((string)$item['service_type']) : '';
// 严禁采信前端传入的 price,强制根据数据库 fa_service / fa_service_spec 与楼层规则计算真实金额
try {
$calc = Home::calcAmount($svc, [
'floor' => $floor,
'has_elevator' => $hasElevator ? 1 : 0,
'urgent' => $urgent,
'goods_num' => $goodsNum,
'spec_id' => $specId,
'service_type' => $reqServiceType,
'strict' => true,
], 0, $this->uid(), 'fixed', 0, []);
} catch (\InvalidArgumentException $e) {
$this->error("第 {$rowNo} 条订单计价校验失败:" . $e->getMessage());
return;
}
if ((float)$calc['pay_amount'] <= 0) {
$this->error("第 {$rowNo} 条订单计算金额异常");
}
$preparedRows[] = [
'svc' => $svc,
'service_id' => (int)$svc['id'],
'cat_id' => (int)$svc['cat_id'],
'service_name' => (string)$svc['name'],
'service_img' => (string)$svc['image'],
'goods_model' => mb_substr(trim((string)(isset($item['goods_model']) ? $item['goods_model'] : '')), 0, 100),
'goods_num' => $goodsNum,
'contact' => mb_substr($contact ?: '买家', 0, 50),
'mobile' => $mobile,
'province' => mb_substr(trim((string)(isset($item['province']) && $item['province'] !== '' ? $item['province'] : ($merchant['province'] ?: '贵州省'))), 0, 50),
'city' => mb_substr(trim((string)(isset($item['city']) && $item['city'] !== '' ? $item['city'] : ($merchant['city'] ?: '贵阳市'))), 0, 50),
'district' => mb_substr(trim((string)(isset($item['district']) ? $item['district'] : ($merchant['district'] ?: ''))), 0, 50),
'address' => mb_substr($address, 0, 255),
'floor' => $floor,
'has_elevator' => $hasElevator ? 1 : 0,
'urgent' => $urgent,
'remark' => mb_substr(trim((string)(isset($item['remark']) ? $item['remark'] : '')), 0, 500),
'appoint_time' => !empty($item['appoint_time']) ? (int)$item['appoint_time'] : null,
'calc' => $calc,
];
}
if (empty($preparedRows)) {
$this->error('未解析到有效的导入订单数据');
}
$ordersCreated = [];
$orderIds = [];
$totalAmount = 0.00;
$isMonthly = false;
$batchNo = 'MB' . date('YmdHis') . mt_rand(1000, 9999);
$now = time();
Db::startTrans();
try {
$batchNo = 'MB' . date('YmdHis') . mt_rand(1000, 9999);
foreach ($items as $idx => $item) {
$contact = trim((string)(isset($item['contact']) ? $item['contact'] : '买家'));
$mobile = trim((string)(isset($item['mobile']) ? $item['mobile'] : ''));
$address = trim((string)(isset($item['address']) ? $item['address'] : ''));
$serviceName = trim((string)(isset($item['service_name']) ? $item['service_name'] : $defaultServiceName));
$goodsModel = trim((string)(isset($item['goods_model']) ? $item['goods_model'] : ''));
$goodsNum = max(1, (int)(isset($item['goods_num']) ? $item['goods_num'] : (isset($item['num']) ? $item['num'] : 1)));
$lockedMerchant = Db::name('merchant')->where('id', (int)$merchant['id'])->lock(true)->find();
if (!$lockedMerchant || $lockedMerchant['verify_status'] !== 'pass') {
throw new \InvalidArgumentException('商家资质状态异常,无法批量发单');
}
$isMonthly = ($lockedMerchant['settle_type'] === 'monthly');
$initStatus = $isMonthly ? OrderStatus::PENDING_ACCEPT : OrderStatus::PENDING_PAY;
$initPayStatus = $isMonthly ? 1 : 0;
$initPayType = $isMonthly ? 'monthly' : '';
$initPayTime = $isMonthly ? $now : null;
if (!$mobile || !$address) {
continue;
}
foreach ($preparedRows as $pRow) {
$orderNo = $this->makeOrderNo();
$verifyCode = str_pad((string)mt_rand(0, 999999), 6, '0', STR_PAD_LEFT);
$calc = $pRow['calc'];
$payAmt = round((float)$calc['pay_amount'], 2);
$unitPrice = round((float)$calc['base_amount'] / max(1, (int)$pRow['goods_num']), 2);
$orderNo = date('YmdHis') . mt_rand(100000, 999999);
$singlePrice = isset($item['price']) && (float)$item['price'] > 0 ? (float)$item['price'] : $defaultPrice;
$baseAmount = round($singlePrice * $goodsNum, 2);
$verifyCode = (string)mt_rand(100000, 999999);
$orderId = Db::name('order')->insertGetId([
$orderId = (int)Db::name('order')->insertGetId([
'order_no' => $orderNo,
'user_id' => $this->uid(),
'service_id' => $globalServiceId,
'service_name' => $serviceName,
'goods_model' => $goodsModel,
'goods_num' => $goodsNum,
'contact' => $contact,
'mobile' => $mobile,
'address' => $address,
'province' => isset($item['province']) ? $item['province'] : '贵州省',
'city' => isset($item['city']) ? $item['city'] : '贵阳市',
'district' => isset($item['district']) ? $item['district'] : '南明区',
'floor' => isset($item['floor']) ? (int)$item['floor'] : 1,
'has_elevator' => isset($item['elevator']) ? (int)$item['elevator'] : 1,
'base_amount' => $baseAmount,
'total_amount' => $baseAmount,
'pay_amount' => $baseAmount,
'status' => OrderStatus::PENDING_ACCEPT,
'pay_status' => 1,
'pay_time' => time(),
'master_id' => 0,
'cat_id' => $pRow['cat_id'],
'service_id' => $pRow['service_id'],
'service_name' => $pRow['service_name'],
'service_image' => $pRow['service_img'],
'price_type' => 'fixed',
'goods_model' => $pRow['goods_model'],
'goods_num' => $pRow['goods_num'],
'contact' => $pRow['contact'],
'mobile' => $pRow['mobile'],
'province' => $pRow['province'],
'city' => $pRow['city'],
'district' => $pRow['district'],
'address' => $pRow['address'],
'floor' => $pRow['floor'],
'has_elevator' => $pRow['has_elevator'],
'urgent' => $pRow['urgent'],
'remark' => $pRow['remark'],
'appoint_time' => $pRow['appoint_time'],
'base_amount' => $calc['base_amount'],
'floor_amount' => $calc['floor_amount'],
'urgent_amount' => $calc['urgent_amount'],
'discount_amount' => 0.00,
'total_amount' => $calc['total_amount'],
'pay_amount' => $payAmt,
'status' => $initStatus,
'pay_status' => $initPayStatus,
'pay_type' => $initPayType,
'pay_time' => $initPayTime,
'transaction_id' => $isMonthly ? ('MONTHLY' . date('YmdHis', $now) . mt_rand(1000, 9999)) : '',
'verify_code' => $verifyCode,
'createtime' => time(),
'updatetime' => time(),
'createtime' => $now,
'updatetime' => $now,
]);
Db::name('order_goods')->insert([
'order_id' => $orderId,
'service_id' => $pRow['service_id'],
'goods_name' => $pRow['service_name'],
'goods_model' => $pRow['goods_model'],
'goods_num' => $pRow['goods_num'],
'goods_image' => $pRow['service_img'],
'addon_services' => '',
'base_price' => $unitPrice,
'createtime' => $now,
]);
Db::name('merchant_order')->insert([
'merchant_id' => (int)$merchant['id'],
'merchant_id' => (int)$lockedMerchant['id'],
'order_id' => $orderId,
'batch_no' => $batchNo,
'createtime' => time(),
'createtime' => $now,
]);
$this->addLog(
$orderId,
$initStatus,
$isMonthly ? "企业批量导单(月结授信,批次 {$batchNo}),已进入接单大厅" : "企业批量导单创建(待支付,批次 {$batchNo})",
'user',
$this->uid()
);
$orderIds[] = $orderId;
$ordersCreated[] = [
'order_id' => $orderId,
'order_no' => $orderNo,
'contact' => $contact,
'amount' => $baseAmount
'contact' => $pRow['contact'],
'amount' => $payAmt,
];
$totalAmount += $baseAmount;
$totalAmount = round($totalAmount + $payAmt, 2);
}
if ($isMonthly) {
Db::name('merchant')->where('id', (int)$lockedMerchant['id'])->update([
'order_count' => (int)$lockedMerchant['order_count'] + count($ordersCreated),
'total_amount' => round((float)$lockedMerchant['total_amount'] + $totalAmount, 2),
'updatetime' => $now,
]);
}
Db::commit();
} catch (\think\exception\HttpResponseException $e) {
Db::rollback();
throw $e;
} catch (\InvalidArgumentException $e) {
Db::rollback();
$this->error($e->getMessage());
return;
} catch (\Exception $e) {
Db::rollback();
\think\Log::error('[Merchant::importBatch] ' . $e->getMessage());
$this->error('批量导入失败:' . $e->getMessage());
return;
}
$this->success('批量下单导入成功!', [
'batch_no' => $batchNo,
'count' => count($ordersCreated),
'total_amount' => round($totalAmount, 2),
'orders' => $ordersCreated,
if ($isMonthly && !empty($orderIds)) {
try {
$masters = Db::name('master')
->where('verify_status', 'pass')
->where('work_status', 'on')
->where('user_id', '<>', $this->uid())
->limit(20)
->select();
$cnt = count($orderIds);
foreach ($masters as $m) {
$this->notify('master', (int)$m['id'], '企业批量新工单就绪', "商家刚发布了 {$cnt} 笔新工单进入接单大厅,请尽快前往查看抢单!", 'order', (int)$orderIds[0]);
}
} catch (\Exception $e) {
\think\Log::error('[Merchant::importBatch broadcast] ' . $e->getMessage());
}
}
$this->success($isMonthly ? '批量下单成功,已按月结授信进入接单大厅!' : '批量导入成功,请完成批次支付', [
'need_pay' => !$isMonthly,
'settle_type' => $isMonthly ? 'monthly' : 'prepay',
'batch_no' => $batchNo,
'count' => count($ordersCreated),
'total_amount' => round($totalAmount, 2),
'order_ids' => $orderIds,
'orders' => $ordersCreated,
]);
}
/**
* 商户端:预付商家按批次号支付待支付批量订单
* @ApiMethod (POST)
*/
public function payBatch()
{
$merchant = $this->requireMerchant();
$batchNo = trim((string)$this->request->post('batch_no', ''));
$payType = trim((string)$this->request->post('pay_type', 'wechat'));
if ($payType === '') {
$payType = 'mock';
}
if ($batchNo === '') {
$this->error('缺少批次号 batch_no');
}
$batchLinks = Db::name('merchant_order')->where('batch_no', $batchNo)->select();
if (empty($batchLinks)) {
$this->error('批次订单不存在');
}
$batchOrderIds = [];
foreach ($batchLinks as $link) {
if ((int)$link['merchant_id'] !== (int)$merchant['id']) {
$this->error('无权操作该批次订单', null, 403);
}
$oid = (int)$link['order_id'];
if ($oid > 0) {
$batchOrderIds[] = $oid;
}
}
$batchOrderIds = array_values(array_unique($batchOrderIds));
if (empty($batchOrderIds)) {
$this->error('该批次下无有效订单');
}
$paidCount = 0;
$paidTotal = 0.00;
$paidOrderIds = [];
$now = time();
Db::startTrans();
try {
$lockedMerchant = Db::name('merchant')->where('id', (int)$merchant['id'])->lock(true)->find();
$orders = Db::name('order')
->where('id', 'in', $batchOrderIds)
->lock(true)
->select();
foreach ($orders as $ord) {
if ((int)$ord['pay_status'] === 1 || (int)$ord['status'] !== OrderStatus::PENDING_PAY) {
continue;
}
$oid = (int)$ord['id'];
$amt = round((float)$ord['pay_amount'], 2);
$txId = 'BATCH' . date('YmdHis', $now) . mt_rand(1000, 9999);
Db::name('order')->where('id', $oid)->update([
'status' => OrderStatus::PENDING_ACCEPT,
'pay_status' => 1,
'pay_type' => $payType,
'pay_time' => $now,
'transaction_id' => $txId,
'updatetime' => $now,
]);
$this->addLog($oid, OrderStatus::PENDING_ACCEPT, "企业批次支付完成(批次 {$batchNo}),订单已进入接单大厅", 'user', $this->uid());
$paidCount++;
$paidTotal = round($paidTotal + $amt, 2);
$paidOrderIds[] = $oid;
}
if ($paidCount === 0) {
throw new \InvalidArgumentException('该批次没有待支付的订单,请勿重复支付');
}
if ($lockedMerchant) {
Db::name('merchant')->where('id', (int)$lockedMerchant['id'])->update([
'order_count' => (int)$lockedMerchant['order_count'] + $paidCount,
'total_amount' => round((float)$lockedMerchant['total_amount'] + $paidTotal, 2),
'updatetime' => $now,
]);
}
Db::commit();
} catch (\think\exception\HttpResponseException $e) {
Db::rollback();
throw $e;
} catch (\InvalidArgumentException $e) {
Db::rollback();
$this->error($e->getMessage());
return;
} catch (\Exception $e) {
Db::rollback();
\think\Log::error('[Merchant::payBatch] ' . $e->getMessage());
$this->error('批次支付失败:' . $e->getMessage());
return;
}
try {
$masters = Db::name('master')
->where('verify_status', 'pass')
->where('work_status', 'on')
->where('user_id', '<>', $this->uid())
->limit(20)
->select();
foreach ($masters as $m) {
$this->notify('master', (int)$m['id'], '企业批量新工单就绪', "同城有 {$paidCount} 笔企业批量工单进入接单大厅,请尽快前往查看抢单!", 'order', (int)$paidOrderIds[0]);
}
} catch (\Exception $e) {
\think\Log::error('[Merchant::payBatch broadcast] ' . $e->getMessage());
}
$this->success('批次支付成功,订单已进入接单大厅', [
'batch_no' => $batchNo,
'count' => $paidCount,
'paid_count' => $paidCount,
'total_amount' => round($paidTotal, 2),
'order_ids' => $paidOrderIds,
]);
}
@@ -741,13 +991,31 @@ class Merchant extends Base
{
$m = $this->merchantInfo();
if (!$m) {
$this->error('请先完成商家入驻', ['need_apply' => 1], 2001);
$curUser = Db::name('user')->where('id', $this->uid())->find();
if ($curUser && !empty($curUser['user_type']) && $curUser['user_type'] === 'merchant') {
$now = time();
$newId = Db::name('merchant')->insertGetId([
'user_id' => $this->uid(),
'name' => !empty($curUser['nickname']) ? (string)$curUser['nickname'] : '商家用户',
'license_no' => '',
'contact' => (string)$curUser['nickname'],
'mobile' => (string)$curUser['mobile'],
'address' => isset($curUser['contact_address']) ? (string)$curUser['contact_address'] : '',
'email' => isset($curUser['email']) ? (string)$curUser['email'] : '',
'verify_status' => 'pass',
'createtime' => $now,
'updatetime' => $now,
]);
return Db::name('merchant')->where('id', $newId)->find();
}
$this->error('请先在「账号信息」切换为商家类型', null, 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);
if ($m['verify_status'] !== 'pass') {
Db::name('merchant')->where('id', (int)$m['id'])->update([
'verify_status' => 'pass',
'updatetime' => time(),
]);
$m['verify_status'] = 'pass';
}
return $m;
}