2417 行
108 KiB
PHP
2417 行
108 KiB
PHP
<?php
|
||
|
||
namespace app\api\controller;
|
||
|
||
use app\common\library\CommissionService;
|
||
use app\common\library\FinanceConfigService;
|
||
use app\common\library\OrderStatus;
|
||
use app\common\library\PaymentService;
|
||
use app\common\library\TiandituService;
|
||
use think\Db;
|
||
|
||
/**
|
||
* 用户端订单
|
||
*/
|
||
class Order extends Base
|
||
{
|
||
protected $noNeedLogin = ['statusList', 'detectExpress', 'remarkTemplates'];
|
||
|
||
/**
|
||
* 状态字典(供前端渲染筛选 Tab)
|
||
*/
|
||
public function statusList()
|
||
{
|
||
$this->success('', ['list' => OrderStatus::all()]);
|
||
}
|
||
|
||
/**
|
||
* 物流单号识别物流公司(依据 fa_express_company 精准前缀规则 + 快递100接口;不唯一识别时不瞎猜)
|
||
*/
|
||
public function detectExpress()
|
||
{
|
||
$raw = strtoupper(trim((string)$this->request->param('express_no', $this->request->param('logistics_no', ''))));
|
||
$no = preg_replace('/[^A-Z0-9]/', '', $raw);
|
||
if ($no === '' || strlen($no) < 5) {
|
||
$this->error('请输入正确的物流单号');
|
||
}
|
||
|
||
$expressRows = Db::name('express_company')
|
||
->field('code,name,prefix_regex')
|
||
->where('code', '<>', 'other')
|
||
->order('weigh desc, id asc')
|
||
->select();
|
||
|
||
$codeMap = [];
|
||
$companyName = '';
|
||
foreach ($expressRows as $er) {
|
||
$eCode = strtolower(trim((string)($er['code'] ?? '')));
|
||
$eName = trim((string)($er['name'] ?? ''));
|
||
$regex = trim((string)($er['prefix_regex'] ?? ''));
|
||
if ($eCode !== '' && $eName !== '') {
|
||
$codeMap[$eCode] = $eName;
|
||
}
|
||
if ($companyName === '' && $regex !== '' && @preg_match($regex, $no) === 1) {
|
||
$companyName = $eName;
|
||
}
|
||
}
|
||
|
||
// 若本地高确定性特征规则未唯一命中,尝试快递100智能单号识别接口(开启严格 TLS 校验与超时保护)
|
||
if ($companyName === '') {
|
||
try {
|
||
$ctx = stream_context_create([
|
||
'http' => ['timeout' => 2.0, 'ignore_errors' => true],
|
||
'ssl' => ['verify_peer' => true, 'verify_peer_name' => true],
|
||
]);
|
||
$resp = @file_get_contents('https://www.kuaidi100.com/autonumber/autoComNum?text=' . urlencode($no), false, $ctx);
|
||
if ($resp) {
|
||
$json = json_decode($resp, true);
|
||
if (!empty($json['auto']) && is_array($json['auto'])) {
|
||
foreach ($json['auto'] as $item) {
|
||
$code = isset($item['comCode']) ? strtolower((string)$item['comCode']) : '';
|
||
if (isset($codeMap[$code])) {
|
||
$companyName = $codeMap[$code];
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} catch (\Throwable $e) {
|
||
\think\Log::warning('[Order::detectExpress] ' . $e->getMessage());
|
||
}
|
||
}
|
||
|
||
if ($companyName === '') {
|
||
$this->success('未能唯一识别物流公司,请手动选择', [
|
||
'express_no' => $no,
|
||
'company_name' => '',
|
||
'matched' => false,
|
||
]);
|
||
return;
|
||
}
|
||
|
||
$this->success('已识别物流公司', [
|
||
'express_no' => $no,
|
||
'company_name' => $companyName,
|
||
'matched' => true,
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* 下单(创建待支付订单)
|
||
* @ApiMethod (POST)
|
||
*/
|
||
public function create()
|
||
{
|
||
// 频次防刷限制:同一用户 3 秒内禁止连续下单,防并发重复创单
|
||
$lastOrderTime = Db::name('order')->where('user_id', $this->uid())->order('id desc')->value('createtime');
|
||
if ($lastOrderTime && (time() - (int)$lastOrderTime) < 3) {
|
||
$this->error('操作过于频繁,请稍候再试');
|
||
}
|
||
|
||
$serviceId = (int)$this->request->post('service_id');
|
||
$addressId = (int)$this->request->post('address_id');
|
||
$appointTime = (int)$this->request->post('appoint_time', 0);
|
||
$urgent = (int)$this->request->post('urgent', 0);
|
||
$couponId = (int)$this->request->post('coupon_id', 0);
|
||
$goodsModel = trim((string)$this->request->post('goods_model'));
|
||
$goodsNum = max(1, (int)$this->request->post('goods_num', 1));
|
||
$specId = (int)$this->request->post('spec_id', 0);
|
||
$reqServiceType = trim((string)$this->request->post('service_type', ''));
|
||
$isCustom = (int)$this->request->post('is_custom', 0);
|
||
$remark = trim((string)$this->request->post('remark'));
|
||
$rawGoodsImagesIn = $this->postMixed('goods_images', '');
|
||
$goodsImages = is_array($rawGoodsImagesIn) ? json_encode($rawGoodsImagesIn, JSON_UNESCAPED_UNICODE) : (string)$rawGoodsImagesIn;
|
||
|
||
// 支持多商品组合 (goods_list / items)
|
||
$goodsListRaw = $this->postMixed('goods_list', $this->postMixed('items', ''));
|
||
$goodsList = [];
|
||
if (!empty($goodsListRaw)) {
|
||
if (is_string($goodsListRaw)) {
|
||
$rawStr = htmlspecialchars_decode($goodsListRaw);
|
||
$decoded = json_decode($rawStr, true);
|
||
if (is_array($decoded)) {
|
||
$goodsList = $decoded;
|
||
}
|
||
} elseif (is_array($goodsListRaw)) {
|
||
if (isset($goodsListRaw[0]) && is_string($goodsListRaw[0])) {
|
||
$rawStr = htmlspecialchars_decode($goodsListRaw[0]);
|
||
$decoded = json_decode($rawStr, true);
|
||
$goodsList = is_array($decoded) ? $decoded : $goodsListRaw;
|
||
} else {
|
||
$goodsList = $goodsListRaw;
|
||
}
|
||
}
|
||
}
|
||
|
||
$service = null;
|
||
if (!empty($goodsList) && is_array($goodsList)) {
|
||
foreach ($goodsList as &$glItem) {
|
||
if (is_string($glItem)) {
|
||
$decGl = json_decode(htmlspecialchars_decode($glItem), true);
|
||
if (is_array($decGl)) {
|
||
$glItem = $decGl;
|
||
}
|
||
}
|
||
}
|
||
unset($glItem);
|
||
$first = reset($goodsList);
|
||
$serviceId = (is_array($first) && isset($first['service_id'])) ? (int)$first['service_id'] : $serviceId;
|
||
}
|
||
|
||
if ($serviceId > 0) {
|
||
$service = Db::name('service')->where('id', $serviceId)->where('status', 'normal')->find();
|
||
}
|
||
if (!$service && empty($goodsList)) {
|
||
$this->error('服务不存在或已下架');
|
||
}
|
||
|
||
// 下单页可选择:一口价 / 师傅报价 / 用户自主定价(未传则沿用服务默认)
|
||
$defaultPriceType = $service ? $service['price_type'] : 'fixed';
|
||
$priceType = trim((string)$this->request->post('price_type', $defaultPriceType));
|
||
if (!in_array($priceType, ['fixed', 'quote', 'custom', 'range'], true)) {
|
||
$priceType = $defaultPriceType;
|
||
}
|
||
$userPrice = round((float)$this->request->post('user_price', 0), 2);
|
||
if ($priceType === 'custom') {
|
||
if ($userPrice < 1 || $userPrice > 100000) {
|
||
$this->error('请填写 1-100000 元的出价');
|
||
}
|
||
} else {
|
||
$userPrice = 0;
|
||
}
|
||
|
||
// 支持直接传 address_id 或传入新地址对象并自动保存
|
||
$address = null;
|
||
if ($addressId > 0) {
|
||
$address = Db::name('address')->where('id', $addressId)->where('user_id', $this->uid())->find();
|
||
}
|
||
if (!$address) {
|
||
$contact = trim((string)$this->request->post('contact'));
|
||
$mobile = trim((string)$this->request->post('mobile'));
|
||
$province = trim((string)$this->request->post('province', ''));
|
||
$city = trim((string)$this->request->post('city', ''));
|
||
$district = trim((string)$this->request->post('district', ''));
|
||
$detailAddr = trim((string)$this->request->post('address'));
|
||
$floor = max(1, (int)$this->request->post('floor', 1));
|
||
$hasElevator = (int)$this->request->post('has_elevator', 1);
|
||
$lng = trim((string)$this->request->post('lng', ''));
|
||
$lat = trim((string)$this->request->post('lat', ''));
|
||
|
||
if ($contact && $mobile && $detailAddr) {
|
||
if ($province === '' || $city === '') {
|
||
$this->error('请选择完整的服务地址省市区');
|
||
}
|
||
if ($lng === '' || $lat === '' || abs((float)$lng) < 0.0001 || abs((float)$lat) < 0.0001) {
|
||
$geo = TiandituService::geocoder(trim($province . $city . $district . $detailAddr));
|
||
if (!$geo && $city !== '') {
|
||
$geo = TiandituService::geocoder(trim($city . $district . $detailAddr));
|
||
}
|
||
if ($geo) {
|
||
$lng = (string)$geo['lng'];
|
||
$lat = (string)$geo['lat'];
|
||
}
|
||
}
|
||
$addressId = Db::name('address')->insertGetId([
|
||
'user_id' => $this->uid(),
|
||
'contact' => $contact,
|
||
'mobile' => $mobile,
|
||
'province' => $province,
|
||
'city' => $city,
|
||
'district' => $district,
|
||
'address' => $detailAddr,
|
||
'lng' => $lng,
|
||
'lat' => $lat,
|
||
'floor' => $floor,
|
||
'has_elevator' => $hasElevator,
|
||
'createtime' => time(),
|
||
'updatetime' => time(),
|
||
]);
|
||
$address = Db::name('address')->where('id', $addressId)->find();
|
||
}
|
||
}
|
||
|
||
if (!$address) {
|
||
$this->error('请选择或填写完整服务地址');
|
||
}
|
||
// 所选地址缺少经纬度时在事务开启前自动通过天地图补全并回填
|
||
if (empty($address['lng']) || empty($address['lat']) || abs((float)$address['lng']) < 0.0001 || abs((float)$address['lat']) < 0.0001) {
|
||
$geo = TiandituService::geocoder(trim(($address['province'] ?? '') . ($address['city'] ?? '') . ($address['district'] ?? '') . ($address['address'] ?? '')));
|
||
if ($geo) {
|
||
$address['lng'] = (string)$geo['lng'];
|
||
$address['lat'] = (string)$geo['lat'];
|
||
if (!empty($address['id'])) {
|
||
Db::name('address')->where('id', (int)$address['id'])->update([
|
||
'lng' => $address['lng'],
|
||
'lat' => $address['lat'],
|
||
'updatetime' => time(),
|
||
]);
|
||
}
|
||
}
|
||
}
|
||
if ($appointTime > 0 && $appointTime < time()) {
|
||
$this->error('预约时间不能早于当前时间');
|
||
}
|
||
|
||
// 多商品组合名称、总件数与图片汇总
|
||
$serviceName = $service ? $service['name'] : '自定义上门服务';
|
||
$catId = $service ? (int)$service['cat_id'] : 0;
|
||
$serviceImage = $service ? $service['image'] : '';
|
||
if (!empty($goodsList) && is_array($goodsList)) {
|
||
$firstG = reset($goodsList);
|
||
if (is_array($firstG) && !empty($firstG['cat_id'])) {
|
||
$catId = (int)$firstG['cat_id'];
|
||
}
|
||
$names = [];
|
||
$totalCount = 0;
|
||
$allImages = [];
|
||
foreach ($goodsList as $idx => $g) {
|
||
if (!is_array($g)) {
|
||
continue;
|
||
}
|
||
$gName = !empty($g['custom_name']) ? $g['custom_name'] : (isset($g['item_name']) ? $g['item_name'] : (isset($g['goods_name']) ? $g['goods_name'] : (isset($g['name']) ? $g['name'] : '服务项')));
|
||
$gNum = isset($g['num']) ? max(1, (int)$g['num']) : (isset($g['goods_num']) ? max(1, (int)$g['goods_num']) : 1);
|
||
$names[] = $gName . 'x' . $gNum;
|
||
$totalCount += $gNum;
|
||
if (!empty($g['images']) && is_array($g['images'])) {
|
||
$allImages = array_merge($allImages, $g['images']);
|
||
}
|
||
}
|
||
if (!empty($names)) {
|
||
$serviceName = implode(' + ', array_slice($names, 0, 3)) . (count($names) > 3 ? ' 等' : '');
|
||
}
|
||
if ($totalCount > 0) {
|
||
$goodsNum = $totalCount;
|
||
}
|
||
if (!empty($allImages)) {
|
||
$goodsImages = $allImages;
|
||
}
|
||
}
|
||
|
||
// 金额统一由 Home::calcAmount 计算(此处先传 couponId=0 核验商品与附加费,卡券在事务内加悲观锁核验扣减)
|
||
try {
|
||
$amount = Home::calcAmount($service, [
|
||
'floor' => (int)$address['floor'],
|
||
'has_elevator' => (int)$address['has_elevator'],
|
||
'urgent' => $urgent,
|
||
'goods_num' => $goodsNum,
|
||
'spec_id' => $specId,
|
||
'service_type' => $reqServiceType,
|
||
'is_custom' => $isCustom,
|
||
'strict' => true,
|
||
], 0, $this->uid(), $priceType, $userPrice, $goodsList);
|
||
} catch (\InvalidArgumentException $e) {
|
||
$this->error($e->getMessage());
|
||
return;
|
||
}
|
||
|
||
if (in_array($priceType, ['fixed', 'range'], true) && (float)$amount['total_amount'] <= 0) {
|
||
$this->error('一口价订单必须关联有效的平台服务项目');
|
||
}
|
||
|
||
if (empty($serviceImage) && !empty($goodsList)) {
|
||
foreach ($goodsList as $item) {
|
||
if (!is_array($item)) {
|
||
continue;
|
||
}
|
||
if (!empty($item['goods_image'])) {
|
||
$serviceImage = $item['goods_image'];
|
||
break;
|
||
} elseif (!empty($item['images']) && is_array($item['images'])) {
|
||
$serviceImage = reset($item['images']);
|
||
break;
|
||
} elseif (!empty($item['service_id'])) {
|
||
$subSvc = Db::name('service')->where('id', (int)$item['service_id'])->find();
|
||
if ($subSvc && !empty($subSvc['image'])) {
|
||
$serviceImage = $subSvc['image'];
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (empty($serviceImage) && $service && !empty($service['image'])) {
|
||
$serviceImage = $service['image'];
|
||
}
|
||
|
||
$amount['price_type'] = $priceType;
|
||
|
||
$orderNo = $this->makeOrderNo();
|
||
$verifyCode = str_pad((string)mt_rand(0, 999999), 6, '0', STR_PAD_LEFT);
|
||
|
||
// 如果有多商品明细,结构化存入 goods_images
|
||
$goodsImagesPayload = is_array($goodsImages) ? json_encode($goodsImages, JSON_UNESCAPED_UNICODE) : (string)$goodsImages;
|
||
if (!empty($goodsList)) {
|
||
$extraPayload = [
|
||
'goods_list' => $goodsList,
|
||
'images' => is_array($goodsImages) ? $goodsImages : [],
|
||
];
|
||
$goodsImagesPayload = json_encode($extraPayload, JSON_UNESCAPED_UNICODE);
|
||
}
|
||
|
||
$orderContact = trim((string)$this->request->post('order_contact', ''));
|
||
$expressNo = trim((string)$this->request->post('express_no', $this->request->post('logistics_no', '')));
|
||
$expressCompany = trim((string)$this->request->post('express_company', $this->request->post('logistics_company', '')));
|
||
$goodsArrived = (int)$this->request->post('goods_arrived', 1);
|
||
$orderSource = trim((string)$this->request->post('order_source', ''));
|
||
$sourceOrderNo = trim((string)$this->request->post('source_order_no', ''));
|
||
$qualReq = trim((string)$this->request->post('qual_req', ''));
|
||
$masterReq = trim((string)$this->request->post('master_req', ''));
|
||
$useVirtual = (int)$this->request->post('use_virtual', 0) ? 1 : 0;
|
||
$autoQuoteTimeout = max(0, min(172800, (int)$this->request->post('auto_quote_timeout', 0)));
|
||
|
||
$uid = $this->uid();
|
||
$now = time();
|
||
$initStatus = $priceType === 'quote' ? OrderStatus::QUOTING : OrderStatus::PENDING_PAY;
|
||
$modeText = [
|
||
'fixed' => '一口价',
|
||
'quote' => '师傅报价',
|
||
'custom' => '用户自主定价',
|
||
'range' => '按面积计价',
|
||
];
|
||
|
||
// 事务外预加载当前生效的按订单维度抽佣规则与默认兜底配置
|
||
$customerType = $this->currentMerchantTeam() ? 'merchant' : 'personal';
|
||
$activeCommRules = CommissionService::getActiveRules();
|
||
$commDefaults = FinanceConfigService::getCommissionDefaults();
|
||
|
||
Db::startTrans();
|
||
try {
|
||
$lockedCouponId = 0;
|
||
if ($couponId > 0) {
|
||
$uc = Db::name('user_coupon')
|
||
->where('id', $couponId)
|
||
->where('user_id', $uid)
|
||
->where('status', 'unused')
|
||
->lock(true)
|
||
->find();
|
||
if (!$uc) {
|
||
throw new \InvalidArgumentException('所选优惠券不存在或已被使用');
|
||
}
|
||
$coupon = Db::name('coupon')
|
||
->where('id', (int)$uc['coupon_id'])
|
||
->where('status', 'normal')
|
||
->find();
|
||
if (!$coupon) {
|
||
throw new \InvalidArgumentException('所选优惠券已失效');
|
||
}
|
||
if (!empty($coupon['start_time']) && (int)$coupon['start_time'] > $now) {
|
||
throw new \InvalidArgumentException('该优惠券尚未到生效时间');
|
||
}
|
||
if ((int)$coupon['end_time'] > 0 && (int)$coupon['end_time'] <= $now) {
|
||
throw new \InvalidArgumentException('所选优惠券已过期');
|
||
}
|
||
$orderTotal = (float)$amount['total_amount'];
|
||
if ($orderTotal < (float)$coupon['min_amount']) {
|
||
throw new \InvalidArgumentException('订单金额未满优惠券使用门槛(满 ¥' . (float)$coupon['min_amount'] . ' 可用)');
|
||
}
|
||
$discount = $coupon['type'] === 'cash'
|
||
? (float)$coupon['amount']
|
||
: round($orderTotal * (1 - (float)$coupon['discount']), 2);
|
||
$discount = round(min(max(0, $discount), $orderTotal), 2);
|
||
$amount['discount_amount'] = $discount;
|
||
$amount['pay_amount'] = max(0, round($orderTotal - $discount, 2));
|
||
$amount['coupon'] = ['id' => (int)$uc['id'], 'name' => $coupon['name']];
|
||
$lockedCouponId = (int)$uc['id'];
|
||
}
|
||
|
||
$commMatch = CommissionService::matchOrderSnapshot([
|
||
'price_type' => $priceType,
|
||
'customer_type' => $customerType,
|
||
'cat_id' => (int)$catId,
|
||
'urgent' => $urgent ? 1 : 0,
|
||
'order_amount' => (float)$amount['pay_amount'] > 0 ? (float)$amount['pay_amount'] : (float)$amount['total_amount'],
|
||
], $activeCommRules, $commDefaults);
|
||
|
||
$id = (int)Db::name('order')->insertGetId([
|
||
'order_no' => $orderNo,
|
||
'user_id' => $uid,
|
||
'master_id' => 0,
|
||
'cat_id' => $catId,
|
||
'service_id' => $service ? (int)$service['id'] : 0,
|
||
'service_name' => $serviceName,
|
||
'service_image' => $serviceImage,
|
||
'price_type' => $priceType,
|
||
'goods_images' => $goodsImagesPayload,
|
||
'goods_model' => mb_substr($goodsModel, 0, 100),
|
||
'goods_num' => $goodsNum,
|
||
'floor' => (int)$address['floor'],
|
||
'has_elevator' => (int)$address['has_elevator'],
|
||
'urgent' => $urgent ? 1 : 0,
|
||
'remark' => mb_substr($remark, 0, 500),
|
||
'contact' => $address['contact'],
|
||
'mobile' => $address['mobile'],
|
||
'use_virtual' => $useVirtual,
|
||
'province' => $address['province'],
|
||
'city' => $address['city'],
|
||
'district' => $address['district'],
|
||
'address' => $address['address'],
|
||
'lng' => $address['lng'],
|
||
'lat' => $address['lat'],
|
||
'order_contact' => mb_substr($orderContact, 0, 100),
|
||
'express_no' => mb_substr($expressNo, 0, 64),
|
||
'express_company' => mb_substr($expressCompany, 0, 64),
|
||
'goods_arrived' => $goodsArrived ? 1 : 0,
|
||
'order_source' => mb_substr($orderSource, 0, 100),
|
||
'source_order_no' => mb_substr($sourceOrderNo, 0, 64),
|
||
'qual_req' => mb_substr($qualReq, 0, 255),
|
||
'master_req' => mb_substr($masterReq, 0, 255),
|
||
'auto_quote_timeout' => $priceType === 'fixed' ? $autoQuoteTimeout : 0,
|
||
'appoint_time' => $appointTime ?: null,
|
||
'base_amount' => $amount['base_amount'],
|
||
'floor_amount' => $amount['floor_amount'],
|
||
'urgent_amount' => $amount['urgent_amount'],
|
||
'discount_amount' => $amount['discount_amount'],
|
||
'total_amount' => $amount['total_amount'],
|
||
'pay_amount' => $amount['pay_amount'],
|
||
'commission_rate' => $commMatch['commission_rate'],
|
||
'commission_snapshot' => $commMatch['commission_snapshot'],
|
||
'status' => $initStatus,
|
||
'pay_status' => 0,
|
||
'coupon_id' => $lockedCouponId,
|
||
'verify_code' => $verifyCode,
|
||
'createtime' => $now,
|
||
'updatetime' => $now,
|
||
]);
|
||
|
||
if ($lockedCouponId > 0) {
|
||
$affectedUc = Db::name('user_coupon')
|
||
->where('id', $lockedCouponId)
|
||
->where('user_id', $uid)
|
||
->where('status', 'unused')
|
||
->update([
|
||
'status' => 'used',
|
||
'use_time' => $now,
|
||
'order_id' => $id,
|
||
]);
|
||
if (!$affectedUc) {
|
||
throw new \InvalidArgumentException('优惠券已被占用,请重新选择');
|
||
}
|
||
}
|
||
|
||
if (!empty($goodsList) && is_array($goodsList)) {
|
||
foreach ($goodsList as $g) {
|
||
if (!is_array($g)) {
|
||
continue;
|
||
}
|
||
// 商品名称快照:优先读取自填名称 custom_name 或 item_name,确保非标自填不丢失
|
||
$finalGoodsName = '';
|
||
if (!empty($g['custom_name'])) {
|
||
$finalGoodsName = trim((string)$g['custom_name']);
|
||
} elseif (!empty($g['item_name'])) {
|
||
$finalGoodsName = trim((string)$g['item_name']);
|
||
} elseif (!empty($g['goods_name'])) {
|
||
$finalGoodsName = trim((string)$g['goods_name']);
|
||
} else {
|
||
$finalGoodsName = (string)$serviceName;
|
||
}
|
||
|
||
// 规格型号快照:优先读取 spec_name 或 goods_model
|
||
$finalSpecModel = '';
|
||
if (!empty($g['spec_name'])) {
|
||
$finalSpecModel = trim((string)$g['spec_name']);
|
||
} elseif (!empty($g['goods_model'])) {
|
||
$finalSpecModel = trim((string)$g['goods_model']);
|
||
} else {
|
||
$finalSpecModel = (string)$goodsModel;
|
||
}
|
||
|
||
// 商品实物图/现场图
|
||
$finalImage = '';
|
||
if (!empty($g['goods_image']) && is_string($g['goods_image'])) {
|
||
$finalImage = $g['goods_image'];
|
||
} elseif (!empty($g['images']) && is_array($g['images'])) {
|
||
$firstImg = reset($g['images']);
|
||
$finalImage = is_string($firstImg) ? $firstImg : (is_array($firstImg) && isset($firstImg['url']) ? (string)$firstImg['url'] : '');
|
||
}
|
||
|
||
// 增项服务快照
|
||
$addonStr = '';
|
||
if (!empty($g['addon_services'])) {
|
||
$addonStr = is_array($g['addon_services']) ? json_encode($g['addon_services'], JSON_UNESCAPED_UNICODE) : (string)$g['addon_services'];
|
||
} elseif (!empty($g['extras']) && is_array($g['extras'])) {
|
||
$hitExtras = array_filter($g['extras'], function ($ex) {
|
||
return is_array($ex) && !empty($ex['num']) && (int)$ex['num'] > 0;
|
||
});
|
||
if (!empty($hitExtras)) {
|
||
$addonStr = json_encode(array_values($hitExtras), JSON_UNESCAPED_UNICODE);
|
||
}
|
||
}
|
||
|
||
$gSid = isset($g['service_id']) ? (int)$g['service_id'] : (int)$serviceId;
|
||
$gUnitPrice = round((float)$amount['base_amount'] / max(1, (int)$goodsNum), 2);
|
||
if ($gSid > 0 && in_array($priceType, ['fixed', 'range'], true)) {
|
||
$gSvc = Db::name('service')->where('id', $gSid)->find();
|
||
if ($gSvc && (float)$gSvc['base_price'] > 0) {
|
||
$gUnitPrice = (float)$gSvc['base_price'];
|
||
}
|
||
}
|
||
|
||
Db::name('order_goods')->insert([
|
||
'order_id' => $id,
|
||
'service_id' => $gSid,
|
||
'goods_name' => mb_substr($finalGoodsName, 0, 100),
|
||
'goods_model' => mb_substr($finalSpecModel, 0, 100),
|
||
'goods_num' => isset($g['num']) ? max(1, (int)$g['num']) : (isset($g['goods_num']) ? max(1, (int)$g['goods_num']) : 1),
|
||
'goods_image' => mb_substr($finalImage, 0, 255),
|
||
'addon_services' => mb_substr($addonStr, 0, 255),
|
||
'base_price' => $gUnitPrice,
|
||
'createtime' => $now,
|
||
]);
|
||
}
|
||
}
|
||
|
||
$this->addLog($id, $initStatus, '用户下单(' . (isset($modeText[$priceType]) ? $modeText[$priceType] : '一口价') . ')', 'user', $uid);
|
||
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('[Order::create] ' . $e->getMessage());
|
||
$this->error('下单失败:' . $e->getMessage());
|
||
return;
|
||
}
|
||
|
||
if ($initStatus === OrderStatus::QUOTING) {
|
||
try {
|
||
$masters = Db::name('master')
|
||
->where('verify_status', 'pass')
|
||
->where('work_status', 'on')
|
||
->where('user_id', '<>', $uid)
|
||
->limit(20)
|
||
->select();
|
||
foreach ($masters as $m) {
|
||
$this->notify('master', (int)$m['id'], '新报价工单通知', '同城有新竞价工单【' . $orderNo . '】进入报价大厅,请尽快前往参与报价!', 'order', $id);
|
||
}
|
||
} catch (\Exception $e) {
|
||
\think\Log::error('[Order::create broadcast] ' . $e->getMessage());
|
||
}
|
||
}
|
||
|
||
// 事务外静默沉淀本次订单中用户真实上传的商品图片到「历史商品上传图片库」(自动跳过系统默认图)
|
||
try {
|
||
$this->archiveOrderUploadedGoodsImages($uid, (int)$catId, (string)$serviceName, $goodsList, $goodsImages);
|
||
} catch (\Exception $e) {
|
||
\think\Log::error('[Order::create archiveGoodsGallery] ' . $e->getMessage());
|
||
}
|
||
|
||
$this->success('下单成功', [
|
||
'order_id' => $id,
|
||
'order_no' => $orderNo,
|
||
'amount' => $amount,
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* 发起订单微信 JSAPI 支付(返回小程序 wx.requestPayment 参数包;0元抵扣单直接核销)
|
||
* @ApiMethod (POST)
|
||
*/
|
||
public function pay()
|
||
{
|
||
$orderId = (int)$this->request->post('order_id');
|
||
$wxCode = trim((string)$this->request->post('wx_code', ''));
|
||
$order = $this->ownOrder($orderId, 'user');
|
||
|
||
try {
|
||
$res = PaymentService::createOrderJsapiPay($order, $this->uid(), 'order', 0, $wxCode);
|
||
} catch (\think\exception\HttpResponseException $e) {
|
||
throw $e;
|
||
} catch (\InvalidArgumentException $e) {
|
||
$this->error($e->getMessage());
|
||
return;
|
||
} catch (\Throwable $e) {
|
||
\think\Log::error('[Order::pay] ' . $e->getMessage());
|
||
$this->error('发起微信支付失败:' . $e->getMessage());
|
||
return;
|
||
}
|
||
|
||
$msg = !empty($res['paid']) ? '支付成功' : '请完成微信支付';
|
||
$this->success($msg, $res);
|
||
}
|
||
|
||
/**
|
||
* 小程序端完成 wx.requestPayment 后主动查单幂等核销(与异步回调双保险闭环)
|
||
* @ApiMethod (POST)
|
||
*/
|
||
public function confirmPay()
|
||
{
|
||
$outTradeNo = trim((string)$this->request->post('out_trade_no', ''));
|
||
$orderId = (int)$this->request->post('order_id', 0);
|
||
|
||
$payOrder = null;
|
||
if ($outTradeNo !== '') {
|
||
$payOrder = Db::name('pay_order')->where('out_trade_no', $outTradeNo)->find();
|
||
} elseif ($orderId > 0) {
|
||
$payOrder = Db::name('pay_order')->where('order_id', $orderId)->order('id desc')->find();
|
||
}
|
||
if (!$payOrder) {
|
||
$this->error('未找到对应的支付交易单');
|
||
}
|
||
|
||
// 强制校验订单归属权限,防止水平越权 IDOR
|
||
$this->ownOrder((int)$payOrder['order_id'], 'user');
|
||
|
||
try {
|
||
$res = PaymentService::confirmOrderPay((string)$payOrder['out_trade_no']);
|
||
} catch (\think\exception\HttpResponseException $e) {
|
||
throw $e;
|
||
} catch (\Throwable $e) {
|
||
\think\Log::error('[Order::confirmPay] ' . $e->getMessage());
|
||
$this->error('查询支付结果失败:' . $e->getMessage());
|
||
return;
|
||
}
|
||
|
||
if (empty($res['paid'])) {
|
||
$stateText = !empty($res['trade_state_text']) ? (string)$res['trade_state_text'] : '暂未确认到支付成功结果';
|
||
$this->error('支付未完成:' . $stateText, $res);
|
||
}
|
||
|
||
$this->success('支付成功', $res);
|
||
}
|
||
|
||
/**
|
||
* 用户在线微信支付施工增项补差价
|
||
* @ApiMethod (POST)
|
||
*/
|
||
public function payDiff()
|
||
{
|
||
$orderId = (int)$this->request->post('order_id');
|
||
$wxCode = trim((string)$this->request->post('wx_code', ''));
|
||
$order = $this->ownOrder($orderId, 'user');
|
||
|
||
try {
|
||
$res = PaymentService::createOrderJsapiPay($order, $this->uid(), 'diff', 0, $wxCode);
|
||
} catch (\think\exception\HttpResponseException $e) {
|
||
throw $e;
|
||
} catch (\InvalidArgumentException $e) {
|
||
$this->error($e->getMessage());
|
||
return;
|
||
} catch (\Throwable $e) {
|
||
\think\Log::error('[Order::payDiff] ' . $e->getMessage());
|
||
$this->error('发起补差价支付失败:' . $e->getMessage());
|
||
return;
|
||
}
|
||
|
||
$this->success('请完成增项补差价微信支付', $res);
|
||
}
|
||
|
||
public function index()
|
||
{
|
||
$status = $this->request->param('status', 'all');
|
||
$keyword = trim((string)$this->request->param('keyword', ''));
|
||
$page = max(1, (int)$this->request->param('page', 1));
|
||
$limit = min(50, max(1, (int)$this->request->param('limit', 10)));
|
||
|
||
$team = $this->currentMerchantTeam();
|
||
$teamUids = $team ? $team['user_ids'] : [$this->uid()];
|
||
$hasTeam = count($teamUids) > 1;
|
||
|
||
$build = function () use ($status, $keyword, $teamUids) {
|
||
$q = Db::name('order')->where('user_id', 'in', $teamUids)->where('user_deleted', 0);
|
||
if ($keyword !== '') {
|
||
$q->where(function ($sub) use ($keyword) {
|
||
$sub->where('contact', 'like', "%{$keyword}%")
|
||
->whereOr('mobile', 'like', "%{$keyword}%")
|
||
->whereOr('order_no', 'like', "%{$keyword}%")
|
||
->whereOr('service_name', 'like', "%{$keyword}%")
|
||
->whereOr('address', 'like', "%{$keyword}%")
|
||
->whereOr('remark', 'like', "%{$keyword}%");
|
||
});
|
||
}
|
||
if ($status === 'active') {
|
||
$q->where('status', 'in', OrderStatus::activeValues());
|
||
} elseif ($status === 'completed' || $status === '8') {
|
||
$q->where('status', OrderStatus::COMPLETED);
|
||
} elseif ($status === 'cancelled' || $status === '9') {
|
||
$q->where('status', OrderStatus::CANCELLED);
|
||
} elseif ($status === 'pending_pay' || $status === '0') {
|
||
$q->where('status', OrderStatus::PENDING_PAY);
|
||
} elseif ($status === 'pending_accept' || $status === '1') {
|
||
$q->where('status', OrderStatus::PENDING_ACCEPT);
|
||
} elseif ($status === 'quoting' || $status === '2') {
|
||
$q->where('status', OrderStatus::QUOTING);
|
||
} elseif ($status === 'scheduled' || $status === '3') {
|
||
$q->where('status', OrderStatus::ACCEPTED);
|
||
} elseif ($status === 'serving' || $status === '4_6') {
|
||
$q->where('status', 'in', [OrderStatus::SCHEDULED, OrderStatus::ARRIVED, OrderStatus::WORKING]);
|
||
} elseif ($status === 'pending_verify' || $status === '7') {
|
||
$q->where('status', OrderStatus::PENDING_VERIFY);
|
||
} elseif ($status !== 'all' && $status !== '') {
|
||
$q->where('status', (int)$status);
|
||
}
|
||
return $q;
|
||
};
|
||
|
||
$total = $build()->count();
|
||
$list = $build()->order('id desc')->page($page, $limit)->select();
|
||
|
||
$orderIds = array_column($list, 'id');
|
||
$allGoods = [];
|
||
if (!empty($orderIds)) {
|
||
$goodsRows = Db::name('order_goods')->where('order_id', 'in', $orderIds)->select();
|
||
foreach ($goodsRows as $gr) {
|
||
$allGoods[$gr['order_id']][] = $gr;
|
||
}
|
||
}
|
||
|
||
$userMap = [];
|
||
if ($hasTeam && !empty($list)) {
|
||
$userMap = Db::name('user')->where('id', 'in', $teamUids)->column('id,nickname,mobile', 'id');
|
||
}
|
||
|
||
foreach ($list as &$row) {
|
||
$row = $this->formatOrder($row);
|
||
$row['has_merchant_team'] = $hasTeam;
|
||
if ($hasTeam && $team) {
|
||
$isOwnerUser = (int)$row['user_id'] === (int)$team['merchant']['user_id'];
|
||
$u = isset($userMap[(int)$row['user_id']]) ? $userMap[(int)$row['user_id']] : null;
|
||
$uMobile = $u ? (string)$u['mobile'] : '';
|
||
$row['account_role'] = $isOwnerUser ? 'owner' : 'member';
|
||
$row['account_role_text'] = $isOwnerUser ? '主账号' : '关联账号';
|
||
$row['account_name'] = ($u && !empty($u['nickname'])) ? $u['nickname'] : ('用户' . substr($uMobile, -4));
|
||
}
|
||
if (!empty($allGoods[$row['id']])) {
|
||
$row['goods_list'] = $allGoods[$row['id']];
|
||
$firstG = $allGoods[$row['id']][0];
|
||
$row['goods_title'] = $firstG['goods_name'] . (!empty($firstG['goods_model']) ? ' (' . $firstG['goods_model'] . ')' : '') . ' x ' . $firstG['goods_num'] . '套';
|
||
if (!empty($firstG['addon_services'])) {
|
||
$row['service_items_text'] = '服务项目: ' . $firstG['addon_services'];
|
||
}
|
||
if (!empty($firstG['goods_image'])) {
|
||
$row['goods_image_url'] = $firstG['goods_image'];
|
||
}
|
||
}
|
||
}
|
||
unset($row);
|
||
|
||
$this->success('', ['list' => $list, 'total' => (int)$total, 'page' => $page]);
|
||
}
|
||
|
||
/**
|
||
* 删除订单(用户端软删除,隐藏订单)
|
||
* @ApiMethod (POST)
|
||
*/
|
||
public function delete()
|
||
{
|
||
$orderId = (int)$this->request->post('order_id');
|
||
$order = $this->ownOrder($orderId, 'user');
|
||
if (!in_array((int)$order['status'], [OrderStatus::CANCELLED, OrderStatus::COMPLETED], true)) {
|
||
$this->error('仅已关闭或已完成的订单支持删除');
|
||
}
|
||
Db::name('order')->where('id', $orderId)->update(['user_deleted' => 1, 'updatetime' => time()]);
|
||
$this->success('订单已删除');
|
||
}
|
||
|
||
/**
|
||
* 一键晒单 / 取消晒单(仅限已完成订单,控制是否在首页服务动态公开展示)
|
||
* @ApiMethod (POST)
|
||
*/
|
||
public function showcase()
|
||
{
|
||
$orderId = (int)$this->request->post('order_id');
|
||
$isShowcase = (int)$this->request->post('is_showcase', 1) ? 1 : 0;
|
||
$content = trim((string)$this->request->post('content', ''));
|
||
|
||
$order = $this->ownOrder($orderId, 'user');
|
||
if ((int)$order['status'] !== OrderStatus::COMPLETED) {
|
||
$this->error('仅已完成的订单支持晒单操作');
|
||
}
|
||
|
||
$now = time();
|
||
Db::startTrans();
|
||
try {
|
||
if ($isShowcase === 1) {
|
||
$existingReview = Db::name('review')->where('order_id', $orderId)->find();
|
||
if ($existingReview) {
|
||
$updateRev = ['is_showcase' => 1];
|
||
if ($content !== '') {
|
||
$updateRev['content'] = mb_substr($content, 0, 500);
|
||
}
|
||
Db::name('review')->where('id', (int)$existingReview['id'])->update($updateRev);
|
||
} else {
|
||
// 若尚未评价直接点击一键晒单,自动复用完工验收图生成五星好评晒单
|
||
$mediaUrls = Db::name('order_media')
|
||
->where('order_id', $orderId)
|
||
->order('id desc')
|
||
->limit(6)
|
||
->column('url');
|
||
$imagesStr = !empty($mediaUrls) ? json_encode(array_values($mediaUrls), JSON_UNESCAPED_UNICODE) : '';
|
||
$defaultContent = $content !== '' ? mb_substr($content, 0, 500) : '师傅准时上门,施工专业细致,验收非常满意,主动晒单推荐!';
|
||
Db::name('review')->insert([
|
||
'order_id' => $orderId,
|
||
'user_id' => $this->uid(),
|
||
'master_id' => (int)$order['master_id'],
|
||
'score' => 5,
|
||
'tags' => '准时,专业,值得推荐',
|
||
'content' => $defaultContent,
|
||
'images' => $imagesStr,
|
||
'is_anonymous' => 0,
|
||
'is_showcase' => 1,
|
||
'status' => 'normal',
|
||
'createtime' => $now,
|
||
]);
|
||
$masterId = (int)$order['master_id'];
|
||
if ($masterId > 0) {
|
||
$avg = Db::name('review')->where('master_id', $masterId)->where('status', 'normal')->avg('score');
|
||
Db::name('master')->where('id', $masterId)->update([
|
||
'score' => round((float)$avg, 2),
|
||
'updatetime' => $now,
|
||
]);
|
||
}
|
||
}
|
||
Db::name('order')->where('id', $orderId)->update([
|
||
'is_review' => 1,
|
||
'is_showcase' => 1,
|
||
'showcase_time' => $now,
|
||
'updatetime' => $now,
|
||
]);
|
||
$showcaseId = $this->syncOrderShowcase($order, 1);
|
||
} else {
|
||
Db::name('order')->where('id', $orderId)->update([
|
||
'is_showcase' => 0,
|
||
'updatetime' => $now,
|
||
]);
|
||
Db::name('review')->where('order_id', $orderId)->update([
|
||
'is_showcase' => 0,
|
||
]);
|
||
$showcaseId = $this->syncOrderShowcase($order, 0);
|
||
}
|
||
Db::commit();
|
||
} catch (\Exception $e) {
|
||
Db::rollback();
|
||
$this->error('操作失败:' . $e->getMessage());
|
||
}
|
||
|
||
$this->success($isShowcase ? '已公开晒单至首页服务动态' : '已取消公开晒单', [
|
||
'is_showcase' => $isShowcase,
|
||
'showcase_id' => isset($showcaseId) ? (int)$showcaseId : 0,
|
||
'is_review' => 1,
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* 订单详情(含状态轨迹与师傅信息)
|
||
*/
|
||
public function detail()
|
||
{
|
||
$orderId = (int)$this->request->param('id') ?: (int)$this->request->param('order_id');
|
||
if ($orderId <= 0) {
|
||
$this->error('订单参数错误');
|
||
}
|
||
$order = Db::name('order')->where('id', $orderId)->find();
|
||
if (!$order) {
|
||
$this->error('订单不存在');
|
||
}
|
||
// 本人订单,或同一商家(主账号 + 关联账号)的订单
|
||
if ((int)$order['user_id'] !== $this->uid()) {
|
||
$uids = $this->currentMerchantUserIds();
|
||
if (!in_array((int)$order['user_id'], $uids, true)) {
|
||
$this->error('无权操作该订单', null, 403);
|
||
}
|
||
}
|
||
|
||
$logs = Db::name('order_log')->where('order_id', $orderId)->order('id asc')->select();
|
||
foreach ($logs as &$log) {
|
||
$log['status_text'] = OrderStatus::label($log['status']);
|
||
$log['time_text'] = date('m-d H:i', (int)$log['createtime']);
|
||
}
|
||
unset($log);
|
||
|
||
$master = null;
|
||
$masterMobileRaw = '';
|
||
if ((int)$order['master_id'] > 0) {
|
||
$m = Db::name('master')->where('id', $order['master_id'])->find();
|
||
if ($m) {
|
||
$rawM = (string)$m['mobile'];
|
||
$maskedM = strlen($rawM) >= 7 ? substr_replace($rawM, '****', 3, 4) : $rawM;
|
||
$canSeeRealMobile = in_array((int)$order['status'], [
|
||
OrderStatus::ACCEPTED,
|
||
OrderStatus::SCHEDULED,
|
||
OrderStatus::ARRIVED,
|
||
OrderStatus::WORKING,
|
||
OrderStatus::PENDING_VERIFY,
|
||
OrderStatus::COMPLETED,
|
||
OrderStatus::AFTERSALE,
|
||
], true);
|
||
if ($canSeeRealMobile) {
|
||
$masterMobileRaw = $rawM;
|
||
}
|
||
$master = [
|
||
'id' => (int)$m['id'],
|
||
'realname' => $m['realname'],
|
||
'avatar' => !empty($m['avatar']) ? (strpos((string)$m['avatar'], 'http') === 0 ? $m['avatar'] : cdnurl($m['avatar'], true)) : '',
|
||
'mobile' => $canSeeRealMobile ? $rawM : $maskedM,
|
||
'mobile_raw' => $masterMobileRaw,
|
||
'mobile_mask' => $maskedM,
|
||
'score' => (float)$m['score'],
|
||
'order_count' => (int)$m['order_count'],
|
||
'level' => (int)$m['level'],
|
||
];
|
||
}
|
||
}
|
||
|
||
$media = Db::name('order_media')->where('order_id', $orderId)->order('id asc')->select();
|
||
|
||
// 报价订单:返回师傅报价列表,供用户比价并选择师傅
|
||
$quotes = [];
|
||
if ($order['price_type'] === 'quote') {
|
||
$rows = Db::name('order_quote')->alias('q')
|
||
->join('master m', 'm.id = q.master_id', 'LEFT')
|
||
->field('q.id,q.order_id,q.master_id,q.amount,q.description,q.valid_hours,q.expire_time,q.status,q.createtime,m.realname,m.mobile,m.avatar,m.score,m.order_count,m.level')
|
||
->where('q.order_id', $orderId)
|
||
->order('q.amount asc,q.id asc')
|
||
->select();
|
||
$statusText = ['wait' => '等待选择', 'accept' => '已选中', 'reject' => '未选中', 'expired' => '已过期'];
|
||
$now = time();
|
||
foreach ($rows as $q) {
|
||
if ($q['status'] === 'wait' && (int)$q['expire_time'] > 0 && (int)$q['expire_time'] < $now) {
|
||
Db::name('order_quote')->where('id', (int)$q['id'])->update(['status' => 'expired']);
|
||
$q['status'] = 'expired';
|
||
}
|
||
$quotes[] = [
|
||
'id' => (int)$q['id'],
|
||
'master_id' => (int)$q['master_id'],
|
||
'realname' => $q['realname'] ?: '师傅',
|
||
'avatar' => $q['avatar'],
|
||
'score' => (float)$q['score'],
|
||
'order_count' => (int)$q['order_count'],
|
||
'level' => (int)$q['level'],
|
||
'amount' => (float)$q['amount'],
|
||
'description' => $q['description'],
|
||
'valid_hours' => (int)$q['valid_hours'],
|
||
'status' => $q['status'],
|
||
'status_text' => isset($statusText[$q['status']]) ? $statusText[$q['status']] : $q['status'],
|
||
'time_text' => date('m-d H:i', (int)$q['createtime']),
|
||
'expire_text' => (int)$q['expire_time'] > 0 ? date('m-d H:i', (int)$q['expire_time']) : '长期有效',
|
||
];
|
||
}
|
||
}
|
||
|
||
$creatorUser = Db::name('user')->where('id', (int)$order['user_id'])->find();
|
||
$rawCreatorMobile = (string)(($creatorUser && !empty($creatorUser['mobile'])) ? $creatorUser['mobile'] : $order['mobile']);
|
||
$maskedCreatorMobile = strlen($rawCreatorMobile) >= 7 ? substr_replace($rawCreatorMobile, '****', 3, 4) : $rawCreatorMobile;
|
||
$creatorName = '';
|
||
if ($creatorUser && !empty($creatorUser['nickname']) && $creatorUser['nickname'] !== $rawCreatorMobile && $creatorUser['nickname'] !== $maskedCreatorMobile) {
|
||
$creatorName = (string)$creatorUser['nickname'];
|
||
} elseif ($creatorUser && !empty($creatorUser['username']) && $creatorUser['username'] !== $rawCreatorMobile && $creatorUser['username'] !== $maskedCreatorMobile) {
|
||
$creatorName = (string)$creatorUser['username'];
|
||
} elseif (!empty($order['contact']) && $order['contact'] !== $rawCreatorMobile && $order['contact'] !== $maskedCreatorMobile) {
|
||
$creatorName = (string)$order['contact'];
|
||
}
|
||
$orderContact = $creatorName !== '' ? ($creatorName . ' · ' . $maskedCreatorMobile) : $maskedCreatorMobile;
|
||
$formattedOrder = $this->formatOrder($order);
|
||
$formattedOrder['order_contact'] = $orderContact;
|
||
$formattedOrder['remark_display'] = !empty($order['remark']) ? $order['remark'] : '无备注';
|
||
|
||
$normalizeUrlList = function ($raw) {
|
||
if (empty($raw)) {
|
||
return [];
|
||
}
|
||
$arr = is_array($raw) ? $raw : (json_decode((string)$raw, true) ?: explode(',', (string)$raw));
|
||
$out = [];
|
||
foreach ($arr as $it) {
|
||
$u = '';
|
||
if (is_string($it)) {
|
||
$u = trim($it);
|
||
} elseif (is_array($it)) {
|
||
foreach (['url', 'tempFilePath', 'path', 'fullurl'] as $k) {
|
||
if (!empty($it[$k]) && is_string($it[$k])) {
|
||
$u = trim($it[$k]);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if ($u !== '' && $u !== 'Array') {
|
||
$full = strpos($u, 'http') === 0 ? $u : cdnurl($u, true);
|
||
if (!in_array($full, $out, true)) {
|
||
$out[] = $full;
|
||
}
|
||
}
|
||
}
|
||
return $out;
|
||
};
|
||
|
||
$parsedPayloadGoods = [];
|
||
$allGoodsImages = [];
|
||
$allEnvImages = [];
|
||
$allGoodsVideos = [];
|
||
if (!empty($order['goods_images'])) {
|
||
$decPayload = json_decode((string)$order['goods_images'], true);
|
||
if (is_array($decPayload)) {
|
||
if (!empty($decPayload['images'])) {
|
||
$allGoodsImages = array_merge($allGoodsImages, $normalizeUrlList($decPayload['images']));
|
||
}
|
||
if (!empty($decPayload['goods_list']) && is_array($decPayload['goods_list'])) {
|
||
$parsedPayloadGoods = $decPayload['goods_list'];
|
||
} elseif (!isset($decPayload['goods_list']) && !isset($decPayload['images'])) {
|
||
$allGoodsImages = array_merge($allGoodsImages, $normalizeUrlList($decPayload));
|
||
}
|
||
} else {
|
||
$allGoodsImages = array_merge($allGoodsImages, $normalizeUrlList((string)$order['goods_images']));
|
||
}
|
||
}
|
||
|
||
foreach ($parsedPayloadGoods as $pgItem) {
|
||
if (!is_array($pgItem)) {
|
||
continue;
|
||
}
|
||
foreach ($normalizeUrlList($pgItem['images'] ?? []) as $u) {
|
||
if (!in_array($u, $allGoodsImages, true)) {
|
||
$allGoodsImages[] = $u;
|
||
}
|
||
}
|
||
foreach ($normalizeUrlList($pgItem['envImages'] ?? ($pgItem['env_images'] ?? [])) as $eu) {
|
||
if (!in_array($eu, $allEnvImages, true)) {
|
||
$allEnvImages[] = $eu;
|
||
}
|
||
}
|
||
foreach ($normalizeUrlList($pgItem['videos'] ?? ($pgItem['goods_videos'] ?? [])) as $vu) {
|
||
if (!in_array($vu, $allGoodsVideos, true)) {
|
||
$allGoodsVideos[] = $vu;
|
||
}
|
||
}
|
||
}
|
||
|
||
$goodsList = Db::name('order_goods')->where('order_id', $orderId)->order('id asc')->select();
|
||
if (!empty($goodsList)) {
|
||
foreach ($goodsList as $gIdx => &$gRow) {
|
||
if (!empty($gRow['goods_image'])) {
|
||
$gRow['goods_image'] = strpos((string)$gRow['goods_image'], 'http') === 0
|
||
? (string)$gRow['goods_image']
|
||
: cdnurl((string)$gRow['goods_image'], true);
|
||
}
|
||
$pg = isset($parsedPayloadGoods[$gIdx]) && is_array($parsedPayloadGoods[$gIdx]) ? $parsedPayloadGoods[$gIdx] : [];
|
||
$rowImgs = $normalizeUrlList($pg['images'] ?? []);
|
||
if (empty($rowImgs) && !empty($gRow['goods_image'])) {
|
||
$rowImgs = [$gRow['goods_image']];
|
||
}
|
||
$gRow['images'] = $rowImgs;
|
||
$gRow['env_images'] = $normalizeUrlList($pg['envImages'] ?? ($pg['env_images'] ?? []));
|
||
$gRow['goods_videos'] = $normalizeUrlList($pg['videos'] ?? ($pg['goods_videos'] ?? []));
|
||
$gRow['service_type_name'] = isset($pg['service_type_name']) ? (string)$pg['service_type_name'] : '安装';
|
||
$gRow['goods_remark'] = isset($pg['remark']) ? trim((string)$pg['remark']) : '';
|
||
$gRow['unit'] = isset($pg['unit']) && $pg['unit'] !== '' ? (string)$pg['unit'] : '套';
|
||
}
|
||
unset($gRow);
|
||
} else {
|
||
$goodsList = [
|
||
[
|
||
'goods_name' => $formattedOrder['service_name'],
|
||
'goods_model' => $formattedOrder['goods_model'] ?: '',
|
||
'goods_num' => max(1, (int)$formattedOrder['goods_num']),
|
||
'goods_image' => $formattedOrder['goods_image_url'],
|
||
'images' => !empty($allGoodsImages) ? $allGoodsImages : ($formattedOrder['goods_image_url'] ? [$formattedOrder['goods_image_url']] : []),
|
||
'env_images' => $allEnvImages,
|
||
'goods_videos' => $allGoodsVideos,
|
||
'service_type_name' => '安装',
|
||
'goods_remark' => '',
|
||
'unit' => '套',
|
||
'addon_services' => '安装x' . max(1, (int)$formattedOrder['goods_num']) . ';',
|
||
'base_price' => (float)$formattedOrder['pay_amount'],
|
||
]
|
||
];
|
||
}
|
||
$formattedOrder['all_goods_images'] = $allGoodsImages;
|
||
$formattedOrder['env_images'] = $allEnvImages;
|
||
$formattedOrder['goods_videos'] = $allGoodsVideos;
|
||
|
||
$review = null;
|
||
$revRow = Db::name('review')->where('order_id', $orderId)->where('status', 'normal')->find();
|
||
if ($revRow) {
|
||
$revTags = [];
|
||
if (!empty($revRow['tags'])) {
|
||
$decTags = json_decode($revRow['tags'], true);
|
||
$revTags = is_array($decTags) ? $decTags : array_values(array_filter(explode(',', (string)$revRow['tags'])));
|
||
}
|
||
$revImgs = [];
|
||
if (!empty($revRow['images'])) {
|
||
$decImgs = json_decode($revRow['images'], true);
|
||
$rawImgs = is_array($decImgs) ? $decImgs : array_values(array_filter(explode(',', (string)$revRow['images'])));
|
||
foreach ($rawImgs as $rImg) {
|
||
if ($rImg) {
|
||
$revImgs[] = cdnurl(trim($rImg), true);
|
||
}
|
||
}
|
||
}
|
||
$review = [
|
||
'id' => (int)$revRow['id'],
|
||
'score' => (int)$revRow['score'],
|
||
'tags' => $revTags,
|
||
'content' => (string)$revRow['content'],
|
||
'images' => $revImgs,
|
||
'is_anonymous' => (int)$revRow['is_anonymous'],
|
||
'is_showcase' => isset($revRow['is_showcase']) ? (int)$revRow['is_showcase'] : (int)$formattedOrder['is_showcase'],
|
||
'time_text' => date('Y-m-d H:i', (int)$revRow['createtime']),
|
||
];
|
||
}
|
||
|
||
$showcaseRow = Db::name('showcase')->where('order_id', $orderId)->where('status', 'normal')->find();
|
||
$formattedOrder['showcase_id'] = $showcaseRow ? (int)$showcaseRow['id'] : 0;
|
||
$formattedOrder['master_mobile_raw'] = $masterMobileRaw;
|
||
|
||
$this->success('', [
|
||
'order' => $formattedOrder,
|
||
'goods_list' => $goodsList,
|
||
'master' => $master,
|
||
'master_mobile_raw' => $masterMobileRaw,
|
||
'logs' => $logs,
|
||
'media' => $media,
|
||
'quotes' => $quotes,
|
||
'review' => $review,
|
||
'can_choose_quote' => $order['price_type'] === 'quote'
|
||
&& (int)$order['status'] === OrderStatus::QUOTING
|
||
&& (int)$order['master_id'] === 0,
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* 报价订单:用户选择师傅报价并发起微信支付托管(付款确认后订单自动锁定师傅并进入已接单)
|
||
* @ApiMethod (POST)
|
||
*/
|
||
public function chooseQuote()
|
||
{
|
||
$orderId = (int)$this->request->post('order_id');
|
||
$quoteId = (int)$this->request->post('quote_id');
|
||
$wxCode = trim((string)$this->request->post('wx_code', ''));
|
||
$order = $this->ownOrder($orderId, 'user');
|
||
|
||
try {
|
||
$res = PaymentService::createOrderJsapiPay($order, $this->uid(), 'quote', $quoteId, $wxCode);
|
||
} catch (\think\exception\HttpResponseException $e) {
|
||
throw $e;
|
||
} catch (\InvalidArgumentException $e) {
|
||
$this->error($e->getMessage());
|
||
return;
|
||
} catch (\Throwable $e) {
|
||
\think\Log::error('[Order::chooseQuote] ' . $e->getMessage());
|
||
$this->error('选择报价并发起支付失败:' . $e->getMessage());
|
||
return;
|
||
}
|
||
|
||
$msg = !empty($res['paid'])
|
||
? '已选定师傅并完成支付托管,请等待师傅联系'
|
||
: '请完成报价托管微信支付';
|
||
$this->success($msg, $res);
|
||
}
|
||
|
||
/**
|
||
* 报价订单:报价列表(详情页轮询/刷新使用)
|
||
*/
|
||
public function quotes()
|
||
{
|
||
$orderId = (int)$this->request->param('order_id');
|
||
$order = $this->ownOrder($orderId, 'user');
|
||
if ($order['price_type'] !== 'quote') {
|
||
$this->success('', ['list' => [], 'can_choose' => false]);
|
||
}
|
||
|
||
$rows = Db::name('order_quote')->alias('q')
|
||
->join('master m', 'm.id = q.master_id', 'LEFT')
|
||
->field('q.id,q.master_id,q.amount,q.description,q.valid_hours,q.expire_time,q.status,q.createtime,m.realname,m.avatar,m.score,m.order_count,m.level')
|
||
->where('q.order_id', $orderId)
|
||
->order('q.amount asc,q.id asc')
|
||
->select();
|
||
$statusText = ['wait' => '等待选择', 'accept' => '已选中', 'reject' => '未选中', 'expired' => '已过期'];
|
||
$list = [];
|
||
foreach ($rows as $q) {
|
||
$list[] = [
|
||
'id' => (int)$q['id'],
|
||
'master_id' => (int)$q['master_id'],
|
||
'realname' => $q['realname'] ?: '师傅',
|
||
'avatar' => $q['avatar'],
|
||
'score' => (float)$q['score'],
|
||
'order_count' => (int)$q['order_count'],
|
||
'amount' => (float)$q['amount'],
|
||
'description' => $q['description'],
|
||
'status' => $q['status'],
|
||
'status_text' => isset($statusText[$q['status']]) ? $statusText[$q['status']] : $q['status'],
|
||
'time_text' => date('m-d H:i', (int)$q['createtime']),
|
||
'expire_text' => (int)$q['expire_time'] > 0 ? date('m-d H:i', (int)$q['expire_time']) : '长期有效',
|
||
];
|
||
}
|
||
|
||
$this->success('', [
|
||
'list' => $list,
|
||
'can_choose' => (int)$order['status'] === OrderStatus::QUOTING && (int)$order['master_id'] === 0,
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* 取消订单(已支付则发起微信原路退款)
|
||
* @ApiMethod (POST)
|
||
*/
|
||
public function cancel()
|
||
{
|
||
$orderId = (int)$this->request->post('order_id');
|
||
$reason = trim((string)$this->request->post('reason', '用户取消'));
|
||
$order = $this->ownOrder($orderId, 'user');
|
||
|
||
if (!OrderStatus::canCancelByUser($order['status'])) {
|
||
$this->error('当前状态(' . OrderStatus::label($order['status']) . ')不可取消,请联系客服');
|
||
}
|
||
if (isset($order['invoice_status']) && $order['invoice_status'] === 'wait') {
|
||
$this->error('该订单正在开票处理中,请先在「开票记录」中撤回开票申请后再取消订单');
|
||
}
|
||
if (isset($order['invoice_status']) && $order['invoice_status'] === 'issued') {
|
||
$this->error('该订单已开具发票,如需取消退款请联系客服办理发票冲红');
|
||
}
|
||
|
||
// 若订单已支付,先在数据库事务外部调用微信支付 APIv3 逐笔发起原路退款(无有效流水严格拦截,严禁静默跳过)
|
||
$refundedAmount = 0.00;
|
||
if ((int)$order['pay_status'] === 1) {
|
||
try {
|
||
$refundedAmount = PaymentService::refundOrder($orderId, '用户取消:' . $reason);
|
||
} catch (\Throwable $re) {
|
||
\think\Log::error('[Order::cancel refund] order_id=' . $orderId . ' reason=' . $re->getMessage());
|
||
$this->error('申请微信原路退款失败:' . $re->getMessage());
|
||
return;
|
||
}
|
||
}
|
||
|
||
$prevMasterId = 0;
|
||
Db::startTrans();
|
||
try {
|
||
$lockedOrder = Db::name('order')->where('id', $orderId)->lock(true)->find();
|
||
if (!$lockedOrder || !OrderStatus::canCancelByUser($lockedOrder['status'])) {
|
||
throw new \InvalidArgumentException('当前状态不可取消,请刷新重试');
|
||
}
|
||
$prevMasterId = (int)$lockedOrder['master_id'];
|
||
$extra = ['cancel_reason' => mb_substr($reason, 0, 255)];
|
||
if ((int)$lockedOrder['pay_status'] === 1) {
|
||
$extra['pay_status'] = 2;
|
||
$extra['refund_amount'] = $refundedAmount > 0 ? $refundedAmount : (float)$lockedOrder['pay_amount'];
|
||
}
|
||
$this->transit($lockedOrder, OrderStatus::CANCELLED, '用户取消:' . $reason, $extra, 'user', $this->uid());
|
||
|
||
if ((int)$lockedOrder['coupon_id'] > 0) {
|
||
Db::name('user_coupon')
|
||
->where('id', (int)$lockedOrder['coupon_id'])
|
||
->where('user_id', (int)$lockedOrder['user_id'])
|
||
->where('order_id', $orderId)
|
||
->where('status', 'used')
|
||
->update([
|
||
'status' => 'unused',
|
||
'use_time' => 0,
|
||
'order_id' => 0,
|
||
]);
|
||
}
|
||
|
||
if ($prevMasterId > 0) {
|
||
$m = Db::name('master')->where('id', $prevMasterId)->find();
|
||
if ($m) {
|
||
$this->notify('master', (int)$m['id'], '订单已取消通知', '客户已取消工单 ' . $lockedOrder['order_no'] . ',原因:' . $reason, 'order', $orderId);
|
||
}
|
||
}
|
||
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('[Order::cancel] ' . $e->getMessage());
|
||
$this->error('取消失败:' . $e->getMessage());
|
||
return;
|
||
}
|
||
|
||
$this->success('订单已取消');
|
||
}
|
||
|
||
/**
|
||
* 客户验收(验收码可选校验)→ 生成结算并给师傅入账
|
||
* @ApiMethod (POST)
|
||
*/
|
||
public function verify()
|
||
{
|
||
$orderId = (int)$this->request->post('order_id');
|
||
$code = trim((string)$this->request->post('verify_code', ''));
|
||
$order = $this->ownOrder($orderId, 'user');
|
||
|
||
if ((int)$order['status'] !== OrderStatus::PENDING_VERIFY) {
|
||
$this->error('当前状态(' . OrderStatus::label($order['status']) . ')不可验收');
|
||
}
|
||
if ($code !== '' && $code !== (string)$order['verify_code']) {
|
||
$this->error('验收码不正确');
|
||
}
|
||
|
||
$masterId = (int)$order['master_id'];
|
||
$payAmount = (float)$order['pay_amount'];
|
||
$settleCalc = CommissionService::calcByOrder($order);
|
||
$rate = $settleCalc['commission_rate'];
|
||
$commission = $settleCalc['commission'];
|
||
$settleAmount = $settleCalc['settle_amount'];
|
||
|
||
Db::startTrans();
|
||
try {
|
||
$lockedOrder = Db::name('order')->where('id', $orderId)->lock(true)->find();
|
||
if (!$lockedOrder || (int)$lockedOrder['status'] !== OrderStatus::PENDING_VERIFY || (int)$lockedOrder['settle_status'] === 1) {
|
||
throw new \InvalidArgumentException('该工单已完成验收或状态已变更,请勿重复提交');
|
||
}
|
||
$masterId = (int)$lockedOrder['master_id'];
|
||
$payAmount = (float)$lockedOrder['pay_amount'];
|
||
$settleCalc = CommissionService::calcByOrder($lockedOrder);
|
||
$rate = $settleCalc['commission_rate'];
|
||
$commission = $settleCalc['commission'];
|
||
$settleAmount = $settleCalc['settle_amount'];
|
||
|
||
$this->transit($lockedOrder, OrderStatus::COMPLETED, '客户验收通过,订单完成', [
|
||
'verify_time' => time(),
|
||
'settle_status' => 1,
|
||
'settle_amount' => $settleAmount,
|
||
], 'user', $this->uid());
|
||
|
||
Db::name('settle')->insert([
|
||
'order_id' => $orderId,
|
||
'master_id' => $masterId,
|
||
'order_amount' => $payAmount,
|
||
'commission_rate' => $rate,
|
||
'commission' => $commission,
|
||
'settle_amount' => $settleAmount,
|
||
'status' => 'settled',
|
||
'settle_time' => time(),
|
||
'createtime' => time(),
|
||
]);
|
||
|
||
if ($masterId > 0) {
|
||
$master = Db::name('master')->where('id', $masterId)->lock(true)->find();
|
||
$newMoney = round((float)$master['money'] + $settleAmount, 2);
|
||
Db::name('master')->where('id', $masterId)->update([
|
||
'money' => $newMoney,
|
||
'order_count' => (int)$master['order_count'] + 1,
|
||
'updatetime' => time(),
|
||
]);
|
||
Db::name('wallet_log')->insert([
|
||
'user_type' => 'master',
|
||
'user_id' => $masterId,
|
||
'type' => 'settle',
|
||
'amount' => $settleAmount,
|
||
'balance' => $newMoney,
|
||
'order_id' => $orderId,
|
||
'remark' => '订单结算入账',
|
||
'createtime' => time(),
|
||
]);
|
||
$this->notify('master', (int)$master['id'], '订单结算入账', '订单 ' . $lockedOrder['order_no'] . ' 已验收,收入 ¥' . $settleAmount, 'order', $orderId);
|
||
}
|
||
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('[Order::verify] ' . $e->getMessage());
|
||
$this->error('验收失败:' . $e->getMessage());
|
||
return;
|
||
}
|
||
|
||
$this->success('验收成功,订单已完成');
|
||
}
|
||
|
||
/**
|
||
* 订单输出格式化
|
||
*/
|
||
protected function formatOrder($row)
|
||
{
|
||
$status = (int)$row['status'];
|
||
$appoint = (int)$row['appoint_time'];
|
||
$mobile = (string)$row['mobile'];
|
||
$mobileMask = strlen($mobile) === 11 ? substr_replace($mobile, '****', 3, 4) : $mobile;
|
||
|
||
$prov = isset($row['province']) ? $row['province'] : '';
|
||
$city = isset($row['city']) ? $row['city'] : '';
|
||
$dist = isset($row['district']) ? $row['district'] : '';
|
||
$addr = isset($row['address']) ? $row['address'] : '';
|
||
$fullAddress = trim($prov . $city . $dist . $addr);
|
||
if (empty($fullAddress)) {
|
||
$fullAddress = $addr;
|
||
}
|
||
|
||
// 状态轨迹提示文案与时间
|
||
$statusTimelineText = OrderStatus::tip($status);
|
||
$statusTime = !empty($row['updatetime']) ? (int)$row['updatetime'] : (int)$row['createtime'];
|
||
if ($status === OrderStatus::CANCELLED) {
|
||
$statusTimelineText = ((int)$row['pay_status'] === 0) ? '订单未支付,您已取消订单' : '订单已取消,退款处理中';
|
||
} elseif ($status === OrderStatus::PENDING_PAY) {
|
||
$statusTimelineText = '订单待支付,请尽快完成支付';
|
||
} elseif ($status === OrderStatus::PENDING_ACCEPT) {
|
||
$statusTimelineText = '订单已进入接单大厅,正在匹配师傅';
|
||
} elseif ($status === OrderStatus::QUOTING) {
|
||
$statusTimelineText = '已有师傅进行报价,请比对选择';
|
||
} elseif ($status === OrderStatus::ACCEPTED) {
|
||
$statusTimelineText = '师傅已接单,请等待师傅联系并上门';
|
||
if (!empty($row['accept_time'])) $statusTime = (int)$row['accept_time'];
|
||
} elseif ($status === OrderStatus::SCHEDULED) {
|
||
$statusTimelineText = '已预约上门服务时间:' . ($appoint ? date('Y-m-d H:i', $appoint) : '尽快上门');
|
||
} elseif ($status === OrderStatus::ARRIVED) {
|
||
$statusTimelineText = '师傅已到达现场,准备开工';
|
||
if (!empty($row['arrive_time'])) $statusTime = (int)$row['arrive_time'];
|
||
} elseif ($status === OrderStatus::WORKING) {
|
||
$statusTimelineText = '师傅施工中,请稍候';
|
||
if (!empty($row['start_time'])) $statusTime = (int)$row['start_time'];
|
||
} elseif ($status === OrderStatus::PENDING_VERIFY) {
|
||
$statusTimelineText = '师傅已完工,请核验施工质量后验收';
|
||
if (!empty($row['finish_time'])) $statusTime = (int)$row['finish_time'];
|
||
} elseif ($status === OrderStatus::COMPLETED) {
|
||
$statusTimelineText = '服务已完成,期待您的评价';
|
||
if (!empty($row['verify_time'])) $statusTime = (int)$row['verify_time'];
|
||
}
|
||
|
||
// 期望上门文本
|
||
$appointDateText = '尽快上门';
|
||
if ($appoint > 0) {
|
||
$hour = (int)date('H', $appoint);
|
||
$period = $hour < 12 ? '上午' : ($hour < 18 ? '下午' : '晚上');
|
||
$appointDateText = date('Y-n-j', $appoint) . ' ' . $period;
|
||
}
|
||
|
||
// 商品与服务概览
|
||
$goodsNum = max(1, (int)$row['goods_num']);
|
||
$goodsTitle = ($row['service_name'] ?: '专业师傅上门服务') . ' x ' . $goodsNum . '套';
|
||
$serviceItemsText = '服务项目: 安装x' . $goodsNum . ';';
|
||
if (!empty($row['goods_model'])) {
|
||
$serviceItemsText = '规格型号: ' . $row['goods_model'] . '; 安装x' . $goodsNum . ';';
|
||
}
|
||
|
||
// 默认商品图/服务图
|
||
$rawSvcImg = !empty($row['service_image']) ? trim((string)$row['service_image']) : '';
|
||
$goodsImageUrl = $rawSvcImg !== ''
|
||
? (strpos($rawSvcImg, 'http') === 0 ? $rawSvcImg : cdnurl($rawSvcImg, true))
|
||
: '/static/icons/service-default.svg';
|
||
|
||
return array_merge($row, [
|
||
'id' => (int)$row['id'],
|
||
'status' => $status,
|
||
'status_text' => OrderStatus::label($status),
|
||
'status_tip' => OrderStatus::tip($status),
|
||
'status_timeline_text'=> $statusTimelineText,
|
||
'status_time_text' => date('Y-m-d H:i:s', $statusTime),
|
||
'status_time_short' => date('Y/m/d H:i:s', $statusTime),
|
||
'can_cancel' => OrderStatus::canCancelByUser($status),
|
||
'can_delete' => in_array($status, [OrderStatus::CANCELLED, OrderStatus::COMPLETED], true),
|
||
'can_verify' => $status === OrderStatus::PENDING_VERIFY,
|
||
'can_review' => $status === OrderStatus::COMPLETED && (int)$row['is_review'] === 0,
|
||
'can_showcase' => $status === OrderStatus::COMPLETED,
|
||
'is_showcase' => isset($row['is_showcase']) ? (int)$row['is_showcase'] : 0,
|
||
'can_pay' => $status === OrderStatus::PENDING_PAY,
|
||
'pay_amount' => (float)$row['pay_amount'],
|
||
'total_amount' => (float)$row['total_amount'],
|
||
'appoint_text' => $appoint ? date('m-d H:i', $appoint) : '尽快上门',
|
||
'appoint_date_text' => $appointDateText,
|
||
'create_text' => date('Y-m-d H:i', (int)$row['createtime']),
|
||
'create_full_text' => date('Y/m/d H:i:s', (int)$row['createtime']),
|
||
'urgent' => (int)$row['urgent'],
|
||
'price_type_text' => $this->priceTypeText($row['price_type']),
|
||
'price_type_tag' => $row['price_type'] === 'quote' ? '报价' : ($row['price_type'] === 'custom' ? '自定价' : '一口价'),
|
||
'is_order_owner' => (int)$row['user_id'] === $this->uid(),
|
||
'mobile_mask' => $mobileMask,
|
||
'full_address' => $fullAddress,
|
||
'goods_title' => $goodsTitle,
|
||
'service_items_text' => $serviceItemsText,
|
||
'goods_image_url' => $goodsImageUrl,
|
||
'diff_amount' => isset($row['diff_amount']) ? (float)$row['diff_amount'] : 0.00,
|
||
'diff_status' => isset($row['diff_status']) ? (int)$row['diff_status'] : 0,
|
||
'diff_remark' => isset($row['diff_remark']) ? (string)$row['diff_remark'] : '',
|
||
'diff_image' => isset($row['diff_image']) ? (string)$row['diff_image'] : '',
|
||
'empty_run_fee' => isset($row['empty_run_fee']) ? (float)$row['empty_run_fee'] : 0.00,
|
||
'use_virtual' => isset($row['use_virtual']) ? (int)$row['use_virtual'] : 0,
|
||
'auto_quote_timeout' => isset($row['auto_quote_timeout']) ? (int)$row['auto_quote_timeout'] : 0,
|
||
'invoice_id' => isset($row['invoice_id']) ? (int)$row['invoice_id'] : 0,
|
||
'invoice_status' => isset($row['invoice_status']) ? (string)$row['invoice_status'] : 'none',
|
||
'invoice_status_text' => (isset($row['invoice_status']) && $row['invoice_status'] === 'issued')
|
||
? '已开具发票'
|
||
: ((isset($row['invoice_status']) && $row['invoice_status'] === 'wait') ? '开票处理中' : '未开票'),
|
||
'can_invoice' => ((int)$row['pay_status'] === 1 && $status === OrderStatus::COMPLETED && (!isset($row['invoice_status']) || $row['invoice_status'] === 'none')),
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* 当前用户所属商家的全部账号(主账号 + 关联账号)
|
||
*/
|
||
protected function currentMerchantUserIds()
|
||
{
|
||
$team = $this->currentMerchantTeam();
|
||
return $team ? $team['user_ids'] : [];
|
||
}
|
||
|
||
/**
|
||
* 计价方式文案(前后端与后台统一口径)
|
||
*/
|
||
protected function priceTypeText($type)
|
||
{
|
||
$map = [
|
||
'fixed' => '一口价',
|
||
'quote' => '师傅报价',
|
||
'custom' => '用户自主定价',
|
||
'range' => '按面积计价',
|
||
];
|
||
return isset($map[$type]) ? $map[$type] : '一口价';
|
||
}
|
||
|
||
/**
|
||
* 超时未验收订单自动核销(默认48小时无争议自动验收结算,保障师傅权益)
|
||
*/
|
||
public static function autoVerifyExpired($timeoutSeconds = 172800)
|
||
{
|
||
$expiredTime = time() - (int)$timeoutSeconds;
|
||
$rows = Db::name('order')
|
||
->where('status', OrderStatus::PENDING_VERIFY)
|
||
->where('settle_status', 0)
|
||
->where('finish_time', '<', $expiredTime)
|
||
->limit(20)
|
||
->select();
|
||
|
||
$count = 0;
|
||
foreach ($rows as $order) {
|
||
$orderId = (int)$order['id'];
|
||
|
||
Db::startTrans();
|
||
try {
|
||
$lockedOrder = Db::name('order')->where('id', $orderId)->lock(true)->find();
|
||
if (!$lockedOrder || (int)$lockedOrder['status'] !== OrderStatus::PENDING_VERIFY || (int)$lockedOrder['settle_status'] === 1) {
|
||
Db::rollback();
|
||
continue;
|
||
}
|
||
|
||
$masterId = (int)$lockedOrder['master_id'];
|
||
$payAmount = (float)$lockedOrder['pay_amount'];
|
||
$settleCalc = CommissionService::calcByOrder($lockedOrder);
|
||
$rate = $settleCalc['commission_rate'];
|
||
$commission = $settleCalc['commission'];
|
||
$settleAmount = $settleCalc['settle_amount'];
|
||
|
||
Db::name('order')->where('id', $orderId)->update([
|
||
'status' => OrderStatus::COMPLETED,
|
||
'verify_time' => time(),
|
||
'settle_status' => 1,
|
||
'settle_amount' => $settleAmount,
|
||
'updatetime' => time(),
|
||
]);
|
||
|
||
Db::name('order_log')->insert([
|
||
'order_id' => $orderId,
|
||
'status' => OrderStatus::COMPLETED,
|
||
'remark' => '完工超48小时未验收,系统自动确认完成并结算',
|
||
'operator_type' => 'system',
|
||
'operator_id' => 0,
|
||
'createtime' => time(),
|
||
]);
|
||
|
||
Db::name('settle')->insert([
|
||
'order_id' => $orderId,
|
||
'master_id' => $masterId,
|
||
'order_amount' => $payAmount,
|
||
'commission_rate' => $rate,
|
||
'commission' => $commission,
|
||
'settle_amount' => $settleAmount,
|
||
'status' => 'settled',
|
||
'settle_time' => time(),
|
||
'createtime' => time(),
|
||
]);
|
||
|
||
if ($masterId > 0) {
|
||
$master = Db::name('master')->where('id', $masterId)->lock(true)->find();
|
||
if ($master) {
|
||
$newMoney = round((float)$master['money'] + $settleAmount, 2);
|
||
Db::name('master')->where('id', $masterId)->update([
|
||
'money' => $newMoney,
|
||
'order_count' => (int)$master['order_count'] + 1,
|
||
'updatetime' => time(),
|
||
]);
|
||
Db::name('wallet_log')->insert([
|
||
'user_type' => 'master',
|
||
'user_id' => $masterId,
|
||
'type' => 'settle',
|
||
'amount' => $settleAmount,
|
||
'balance' => $newMoney,
|
||
'order_id' => $orderId,
|
||
'remark' => '工单超时自动验收结算入账',
|
||
'createtime' => time(),
|
||
]);
|
||
Db::name('message')->insert([
|
||
'receive_type' => 'master',
|
||
'receive_id' => (int)$master['id'],
|
||
'type' => 'order',
|
||
'title' => '订单自动验收结算入账',
|
||
'content' => '订单 ' . $order['order_no'] . ' 完工满48小时已自动验收,收入 ¥' . $settleAmount,
|
||
'order_id' => $orderId,
|
||
'is_read' => 0,
|
||
'createtime' => time(),
|
||
]);
|
||
}
|
||
}
|
||
Db::commit();
|
||
$count++;
|
||
} catch (\Exception $e) {
|
||
Db::rollback();
|
||
\think\Log::error('[Order::autoVerifyExpired] ' . $e->getMessage());
|
||
}
|
||
}
|
||
return $count;
|
||
}
|
||
|
||
/**
|
||
* 更新用户订单标记/备注(持久化入库)
|
||
* @ApiMethod (POST)
|
||
*/
|
||
public function updateNote()
|
||
{
|
||
$orderId = (int)$this->request->post('order_id');
|
||
$note = trim((string)$this->request->post('remark', ''));
|
||
$order = $this->ownOrder($orderId, 'user');
|
||
|
||
$safeNote = mb_substr($note, 0, 255);
|
||
Db::name('order')->where('id', (int)$order['id'])->update([
|
||
'remark' => $safeNote,
|
||
'updatetime' => time(),
|
||
]);
|
||
|
||
$this->success('备注已保存', ['remark' => $safeNote]);
|
||
}
|
||
|
||
/**
|
||
* 用户/商家专属商品图库列表(含已入库商品图 + 历史订单实拍商品图沉淀)
|
||
*/
|
||
public function goodsGallery()
|
||
{
|
||
$uid = $this->uid();
|
||
$catId = (int)$this->request->param('cat_id', 0);
|
||
$preferCatId = (int)$this->request->param('prefer_cat_id', 0);
|
||
$keyword = trim((string)$this->request->param('keyword', ''));
|
||
|
||
$cats = Db::name('service_cat')
|
||
->field('id,name')
|
||
->where('status', 'normal')
|
||
->where('id', '<>', 15)
|
||
->order('weigh desc,id asc')
|
||
->select();
|
||
$catNameMap = [];
|
||
foreach ($cats as $c) {
|
||
$catNameMap[(int)$c['id']] = (string)$c['name'];
|
||
}
|
||
|
||
$q = Db::name('user_goods_gallery')->where('user_id', $uid);
|
||
if ($catId > 0) {
|
||
$q->where('cat_id', $catId);
|
||
}
|
||
if ($keyword !== '') {
|
||
$q->where('title', 'like', '%' . $keyword . '%');
|
||
}
|
||
$libRows = $q->order('id desc')->limit(200)->select();
|
||
|
||
$list = [];
|
||
$seenUrls = [];
|
||
foreach ($libRows as $r) {
|
||
$rawImg = trim((string)$r['image']);
|
||
if ($rawImg === '' || $this->isSystemDefaultImageUrl($rawImg)) {
|
||
continue;
|
||
}
|
||
$fullUrl = strpos($rawImg, 'http') === 0 ? $rawImg : cdnurl($rawImg, true);
|
||
$seenUrls[$fullUrl] = true;
|
||
$cid = (int)$r['cat_id'];
|
||
$list[] = [
|
||
'id' => (int)$r['id'],
|
||
'cat_id' => $cid,
|
||
'cat_name' => isset($catNameMap[$cid]) ? $catNameMap[$cid] : '通用商品',
|
||
'title' => (string)($r['title'] ?: '历史上传商品'),
|
||
'image_url' => $fullUrl,
|
||
'source' => 'library',
|
||
'can_delete' => 1,
|
||
'time_text' => $r['createtime'] ? date('Y-m-d', (int)$r['createtime']) : '',
|
||
];
|
||
}
|
||
|
||
// 自动沉淀本人历史订单上传过的真实非系统参考图,方便一键复用
|
||
$histQuery = Db::name('order')
|
||
->field('id,cat_id,service_name,goods_images,createtime')
|
||
->where('user_id', $uid)
|
||
->where('user_deleted', 0)
|
||
->where('goods_images', '<>', '');
|
||
if ($catId > 0) {
|
||
$histQuery->where('cat_id', $catId);
|
||
}
|
||
$histOrders = $histQuery->order('id desc')->limit(50)->select();
|
||
|
||
foreach ($histOrders as $ho) {
|
||
$dec = json_decode((string)$ho['goods_images'], true);
|
||
$candidates = [];
|
||
if (is_array($dec)) {
|
||
if (!empty($dec['goods_list']) && is_array($dec['goods_list'])) {
|
||
foreach ($dec['goods_list'] as $gi) {
|
||
if (!is_array($gi)) {
|
||
continue;
|
||
}
|
||
$gTitle = !empty($gi['custom_name']) ? (string)$gi['custom_name'] : (!empty($gi['item_name']) ? (string)$gi['item_name'] : (string)$ho['service_name']);
|
||
if (!empty($gi['spec_name']) && $gi['spec_name'] !== $gTitle) {
|
||
$gTitle .= '(' . $gi['spec_name'] . ')';
|
||
}
|
||
$imgs = isset($gi['images']) && is_array($gi['images']) ? $gi['images'] : [];
|
||
foreach ($imgs as $im) {
|
||
if (is_string($im) && trim($im) !== '') {
|
||
$candidates[] = ['title' => $gTitle ?: '历史订单商品', 'img' => trim($im)];
|
||
}
|
||
}
|
||
}
|
||
} elseif (!empty($dec['images']) && is_array($dec['images'])) {
|
||
foreach ($dec['images'] as $im) {
|
||
if (is_string($im) && trim($im) !== '') {
|
||
$candidates[] = ['title' => (string)($ho['service_name'] ?: '历史订单商品'), 'img' => trim($im)];
|
||
}
|
||
}
|
||
}
|
||
}
|
||
foreach ($candidates as $cand) {
|
||
$rawIm = $cand['img'];
|
||
// 排除 SVG 图标与系统图库静态参考图,仅保留用户真实上传过的历史商品图
|
||
if ($this->isSystemDefaultImageUrl($rawIm)) {
|
||
continue;
|
||
}
|
||
if ($keyword !== '' && mb_strpos($cand['title'], $keyword) === false) {
|
||
continue;
|
||
}
|
||
$fullUrl = strpos($rawIm, 'http') === 0 ? $rawIm : cdnurl($rawIm, true);
|
||
if (isset($seenUrls[$fullUrl])) {
|
||
continue;
|
||
}
|
||
$seenUrls[$fullUrl] = true;
|
||
$cid = (int)$ho['cat_id'];
|
||
$list[] = [
|
||
'id' => 0,
|
||
'cat_id' => $cid,
|
||
'cat_name' => isset($catNameMap[$cid]) ? $catNameMap[$cid] : '历史订单',
|
||
'title' => $cand['title'],
|
||
'image_url' => $fullUrl,
|
||
'source' => 'order',
|
||
'can_delete' => 0,
|
||
'time_text' => $ho['createtime'] ? date('Y-m-d', (int)$ho['createtime']) : '',
|
||
];
|
||
}
|
||
}
|
||
|
||
if ($catId === 0 && $preferCatId > 0 && count($list) > 1) {
|
||
usort($list, function ($a, $b) use ($preferCatId) {
|
||
$aMatch = ((int)$a['cat_id'] === $preferCatId) ? 1 : 0;
|
||
$bMatch = ((int)$b['cat_id'] === $preferCatId) ? 1 : 0;
|
||
if ($aMatch !== $bMatch) {
|
||
return $bMatch <=> $aMatch;
|
||
}
|
||
return 0;
|
||
});
|
||
}
|
||
|
||
$this->success('', [
|
||
'cats' => $cats,
|
||
'list' => $list,
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* 判断图片是否为平台系统默认参考图(避免将系统默认图混入用户个人的历史商品上传图库)
|
||
*/
|
||
protected function isSystemDefaultImageUrl($url)
|
||
{
|
||
$u = strtolower(trim((string)$url));
|
||
if ($u === '' || substr($u, -4) === '.svg') {
|
||
return true;
|
||
}
|
||
if (strpos($u, '/gallery/') !== false || strpos($u, '/goods/') !== false || strpos($u, '/uploads/service/') !== false || strpos($u, '/uploads/services/') !== false) {
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* 订单创建成功后将用户真实上传的商品图片自动沉淀到「历史商品上传图片库」
|
||
*/
|
||
protected function archiveOrderUploadedGoodsImages($uid, $defaultCatId, $defaultTitle, $goodsList, $rawGoodsImages)
|
||
{
|
||
$uid = (int)$uid;
|
||
if ($uid <= 0) {
|
||
return;
|
||
}
|
||
$entries = [];
|
||
if (!empty($goodsList) && is_array($goodsList)) {
|
||
foreach ($goodsList as $g) {
|
||
if (!is_array($g)) {
|
||
continue;
|
||
}
|
||
$gCatId = !empty($g['cat_id']) ? (int)$g['cat_id'] : (int)$defaultCatId;
|
||
$gTitle = !empty($g['custom_name']) ? trim((string)$g['custom_name']) : (!empty($g['item_name']) ? trim((string)$g['item_name']) : trim((string)$defaultTitle));
|
||
if (!empty($g['spec_name']) && $g['spec_name'] !== $gTitle) {
|
||
$gTitle .= '(' . trim((string)$g['spec_name']) . ')';
|
||
}
|
||
$imgs = isset($g['images']) && is_array($g['images']) ? $g['images'] : [];
|
||
foreach ($imgs as $im) {
|
||
if (is_string($im) && !$this->isSystemDefaultImageUrl($im)) {
|
||
$entries[] = ['cat_id' => $gCatId, 'title' => mb_substr($gTitle ?: '历史上传商品', 0, 80), 'url' => trim($im)];
|
||
}
|
||
}
|
||
}
|
||
} elseif (!empty($rawGoodsImages)) {
|
||
$arr = is_array($rawGoodsImages) ? $rawGoodsImages : (json_decode((string)$rawGoodsImages, true) ?: explode(',', (string)$rawGoodsImages));
|
||
foreach ($arr as $im) {
|
||
if (is_string($im) && !$this->isSystemDefaultImageUrl($im)) {
|
||
$entries[] = ['cat_id' => (int)$defaultCatId, 'title' => mb_substr(trim((string)$defaultTitle) ?: '历史上传商品', 0, 80), 'url' => trim($im)];
|
||
}
|
||
}
|
||
}
|
||
|
||
if (empty($entries)) {
|
||
return;
|
||
}
|
||
$now = time();
|
||
foreach ($entries as $ent) {
|
||
$rawU = $ent['url'];
|
||
if ($rawU === '' || strlen($rawU) > 255) {
|
||
continue;
|
||
}
|
||
$fullUrl = strpos($rawU, 'http') === 0 ? $rawU : cdnurl($rawU, true);
|
||
$exists = Db::name('user_goods_gallery')
|
||
->where('user_id', $uid)
|
||
->where('image', 'in', [$rawU, $fullUrl])
|
||
->value('id');
|
||
if ($exists) {
|
||
continue;
|
||
}
|
||
Db::name('user_goods_gallery')->insert([
|
||
'user_id' => $uid,
|
||
'cat_id' => max(0, (int)$ent['cat_id']),
|
||
'title' => $ent['title'],
|
||
'image' => $fullUrl,
|
||
'createtime' => $now,
|
||
]);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 安全读取兼容 JSON 数组与字符串的 POST 参数(规避 ThinkPHP 5 无 /a 修饰符时对数组抛类型异常)
|
||
*/
|
||
protected function postMixed($key, $default = null)
|
||
{
|
||
$all = $this->request->post(false);
|
||
if (is_array($all) && array_key_exists($key, $all)) {
|
||
return $all[$key];
|
||
}
|
||
return $default;
|
||
}
|
||
|
||
/**
|
||
* 上传保存商品图至个人专属商品图库
|
||
* @ApiMethod (POST)
|
||
*/
|
||
public function saveGoodsGallery()
|
||
{
|
||
$uid = $this->uid();
|
||
$catId = max(0, (int)$this->request->post('cat_id', 0));
|
||
$title = mb_substr(trim((string)$this->request->post('title', '')), 0, 80);
|
||
$singleImg = trim((string)$this->request->post('image', ''));
|
||
$rawImages = $this->postMixed('images', '');
|
||
|
||
$imgList = [];
|
||
if ($singleImg !== '') {
|
||
$imgList[] = $singleImg;
|
||
}
|
||
if (!empty($rawImages)) {
|
||
if (is_array($rawImages)) {
|
||
$imgList = array_merge($imgList, $rawImages);
|
||
} elseif (is_string($rawImages)) {
|
||
$dec = json_decode(htmlspecialchars_decode($rawImages), true);
|
||
if (is_array($dec)) {
|
||
$imgList = array_merge($imgList, $dec);
|
||
} else {
|
||
$imgList = array_merge($imgList, explode(',', $rawImages));
|
||
}
|
||
}
|
||
}
|
||
|
||
if (empty($imgList)) {
|
||
$this->error('请上传有效的商品图片');
|
||
}
|
||
|
||
$validUrls = [];
|
||
foreach ($imgList as $u) {
|
||
if (!is_string($u)) {
|
||
continue;
|
||
}
|
||
$u = trim($u);
|
||
if ($u === '' || strlen($u) > 255 || $this->isSystemDefaultImageUrl($u)) {
|
||
continue;
|
||
}
|
||
if (strpos($u, 'http://') === 0 || strpos($u, 'https://') === 0 || strpos($u, '/') === 0) {
|
||
$validUrls[] = $u;
|
||
}
|
||
}
|
||
$validUrls = array_values(array_unique($validUrls));
|
||
if (empty($validUrls)) {
|
||
$this->success('系统默认图片无需重复保存到历史图库', ['added' => 0, 'list' => []]);
|
||
}
|
||
|
||
if ($title === '' && $catId > 0) {
|
||
$cName = (string)Db::name('service_cat')->where('id', $catId)->value('name');
|
||
$title = $cName !== '' ? ($cName . '商品实拍') : '常用商品图';
|
||
} elseif ($title === '') {
|
||
$title = '常用商品图';
|
||
}
|
||
|
||
$now = time();
|
||
$saved = [];
|
||
$addedCount = 0;
|
||
foreach ($validUrls as $u) {
|
||
$fullUrl = strpos($u, 'http') === 0 ? $u : cdnurl($u, true);
|
||
$exists = Db::name('user_goods_gallery')
|
||
->where('user_id', $uid)
|
||
->where('image', 'in', [$u, $fullUrl])
|
||
->find();
|
||
if ($exists) {
|
||
$saved[] = [
|
||
'id' => (int)$exists['id'],
|
||
'cat_id' => (int)$exists['cat_id'],
|
||
'title' => (string)$exists['title'],
|
||
'image_url' => strpos((string)$exists['image'], 'http') === 0 ? (string)$exists['image'] : cdnurl((string)$exists['image'], true),
|
||
'source' => 'library',
|
||
'can_delete' => 1,
|
||
];
|
||
continue;
|
||
}
|
||
$newId = (int)Db::name('user_goods_gallery')->insertGetId([
|
||
'user_id' => $uid,
|
||
'cat_id' => $catId,
|
||
'title' => $title,
|
||
'image' => $fullUrl,
|
||
'createtime' => $now,
|
||
]);
|
||
$addedCount++;
|
||
$saved[] = [
|
||
'id' => $newId,
|
||
'cat_id' => $catId,
|
||
'title' => $title,
|
||
'image_url' => $fullUrl,
|
||
'source' => 'library',
|
||
'can_delete' => 1,
|
||
];
|
||
}
|
||
|
||
$this->success('已存入商品图库', ['added' => $addedCount, 'list' => $saved]);
|
||
}
|
||
|
||
/**
|
||
* 删除本人商品图库中的图片(严格校验 user_id 防越权)
|
||
* @ApiMethod (POST)
|
||
*/
|
||
public function deleteGoodsGallery()
|
||
{
|
||
$uid = $this->uid();
|
||
$singleId = (int)$this->request->post('id', 0);
|
||
$rawIds = $this->postMixed('ids', '');
|
||
|
||
$idArr = [];
|
||
if ($singleId > 0) {
|
||
$idArr[] = $singleId;
|
||
}
|
||
if (!empty($rawIds)) {
|
||
if (is_array($rawIds)) {
|
||
$idArr = array_merge($idArr, array_map('intval', $rawIds));
|
||
} elseif (is_string($rawIds)) {
|
||
$dec = json_decode(htmlspecialchars_decode($rawIds), true);
|
||
$arr = is_array($dec) ? $dec : explode(',', $rawIds);
|
||
$idArr = array_merge($idArr, array_map('intval', $arr));
|
||
}
|
||
}
|
||
$idArr = array_values(array_unique(array_filter($idArr, function ($v) {
|
||
return $v > 0;
|
||
})));
|
||
if (empty($idArr)) {
|
||
$this->error('请选择要删除的图片');
|
||
}
|
||
|
||
$deleted = Db::name('user_goods_gallery')
|
||
->where('user_id', $uid)
|
||
->where('id', 'in', $idArr)
|
||
->delete();
|
||
if (!$deleted) {
|
||
$this->error('图片不存在或无权删除');
|
||
}
|
||
$this->success('已从商品图库移除', ['deleted' => (int)$deleted]);
|
||
}
|
||
|
||
/**
|
||
* 获取当前用户已保存的常用商品模板列表(自动关联刷新最新服务价格与工项)
|
||
*/
|
||
public function goodsTemplates()
|
||
{
|
||
$uid = $this->uid();
|
||
$preferCatId = (int)$this->request->param('cat_id', 0);
|
||
|
||
$rows = Db::name('user_goods_template')
|
||
->where('user_id', $uid)
|
||
->order('updatetime desc, id desc')
|
||
->limit(50)
|
||
->select();
|
||
|
||
if (empty($rows)) {
|
||
$this->success('', ['list' => []]);
|
||
return;
|
||
}
|
||
|
||
$svcIds = array_values(array_unique(array_filter(array_map(function ($r) {
|
||
return (int)$r['service_id'];
|
||
}, $rows))));
|
||
|
||
$homeCtrl = new Home($this->request);
|
||
$svcMap = [];
|
||
if (!empty($svcIds)) {
|
||
$rawSvcs = Db::name('service')->where('id', 'in', $svcIds)->where('status', 'normal')->select();
|
||
$formattedSvcs = $homeCtrl->formatServices($rawSvcs);
|
||
foreach ($formattedSvcs as $fs) {
|
||
$svcMap[(int)$fs['id']] = $fs;
|
||
}
|
||
}
|
||
|
||
$catIconRows = Db::name('service_cat')->where('status', 'normal')->column('icon', 'id');
|
||
|
||
$list = [];
|
||
foreach ($rows as $r) {
|
||
$sid = (int)$r['service_id'];
|
||
$specId = (int)$r['spec_id'];
|
||
$cid = (int)$r['cat_id'];
|
||
$isCustom = (int)$r['is_custom'] === 1 || $cid === 15;
|
||
$fs = isset($svcMap[$sid]) ? $svcMap[$sid] : null;
|
||
|
||
$images = [];
|
||
if (!empty($r['images_json'])) {
|
||
$decIm = json_decode((string)$r['images_json'], true);
|
||
if (is_array($decIm)) {
|
||
foreach ($decIm as $im) {
|
||
if (is_string($im) && trim($im) !== '') {
|
||
$images[] = strpos($im, 'http') === 0 ? $im : cdnurl($im, true);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
$savedExtras = [];
|
||
if (!empty($r['extras_json'])) {
|
||
$decEx = json_decode((string)$r['extras_json'], true);
|
||
if (is_array($decEx)) {
|
||
$savedExtras = $decEx;
|
||
}
|
||
}
|
||
|
||
// 合并实时服务工项、规格单价与增项
|
||
$serviceTypes = [];
|
||
$extras = $savedExtras;
|
||
$basePrice = 0.0;
|
||
$priceType = $isCustom ? 'quote' : 'fixed';
|
||
$unit = (string)($r['unit'] ?: '个');
|
||
|
||
if ($fs && !$isCustom) {
|
||
$priceType = (string)$fs['price_type'];
|
||
$unit = (string)($fs['unit'] ?: $unit);
|
||
$specPrice = 0.0;
|
||
if ($specId > 0 && !empty($fs['specs'])) {
|
||
foreach ($fs['specs'] as $spItem) {
|
||
if ((int)$spItem['id'] === $specId && (float)$spItem['base_price'] > 0) {
|
||
$specPrice = (float)$spItem['base_price'];
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
$rawSt = !empty($fs['service_types']) ? $fs['service_types'] : [];
|
||
$hasDef = false;
|
||
foreach ($rawSt as $x) {
|
||
if (!empty($x['is_default'])) {
|
||
$hasDef = true;
|
||
break;
|
||
}
|
||
}
|
||
foreach ($rawSt as $idx => $st) {
|
||
$isDef = !empty($st['is_default']) || (!$hasDef && $idx === 0);
|
||
$pVal = ($isDef && $specPrice > 0) ? $specPrice : (float)($st['price'] ?? 0);
|
||
$st['price'] = $pVal;
|
||
$serviceTypes[] = $st;
|
||
if ((string)$st['code'] === (string)$r['service_type']) {
|
||
$basePrice = $pVal;
|
||
}
|
||
}
|
||
if ($basePrice <= 0) {
|
||
$basePrice = $specPrice > 0 ? $specPrice : (float)$fs['base_price'];
|
||
}
|
||
|
||
// 将保存的增项数量映射回最新的增项列表
|
||
$savedQtyMap = [];
|
||
foreach ($savedExtras as $sex) {
|
||
if (is_array($sex) && !empty($sex['name'])) {
|
||
$savedQtyMap[(string)$sex['name']] = max(0, (int)($sex['num'] ?? 0));
|
||
}
|
||
}
|
||
$freshExtras = [];
|
||
foreach (($fs['addons'] ?? []) as $aIdx => $ad) {
|
||
$aName = (string)($ad['name'] ?? '');
|
||
$freshExtras[] = [
|
||
'id' => $ad['id'] ?? ($aIdx + 1),
|
||
'key' => 'addon_' . ($ad['id'] ?? ($aIdx + 1)),
|
||
'name' => $aName,
|
||
'unit' => (string)($ad['unit'] ?? '份'),
|
||
'price' => (float)($ad['price'] ?? 0),
|
||
'num' => isset($savedQtyMap[$aName]) ? $savedQtyMap[$aName] : 0,
|
||
];
|
||
}
|
||
$extras = $freshExtras;
|
||
}
|
||
|
||
$catIconRaw = isset($catIconRows[$cid]) ? (string)$catIconRows[$cid] : '';
|
||
$catIconUrl = $catIconRaw !== '' ? (strpos($catIconRaw, 'http') === 0 ? $catIconRaw : cdnurl($catIconRaw, true)) : '';
|
||
|
||
$list[] = [
|
||
'id' => (int)$r['id'],
|
||
'tpl_name' => (string)$r['tpl_name'],
|
||
'cat_id' => $cid,
|
||
'cat_name' => (string)$r['cat_name'],
|
||
'cat_icon_url' => $catIconUrl,
|
||
'service_id' => $sid,
|
||
'item_name' => (string)$r['item_name'],
|
||
'spec_id' => $specId,
|
||
'spec_name' => (string)$r['spec_name'],
|
||
'unit' => $unit,
|
||
'num' => max(1, (int)$r['num']),
|
||
'service_type' => (string)($r['service_type'] ?: 'install'),
|
||
'service_type_name' => (string)($r['service_type_name'] ?: '安装'),
|
||
'service_types' => $serviceTypes,
|
||
'price_type' => $priceType,
|
||
'base_price' => $basePrice,
|
||
'is_custom' => $isCustom,
|
||
'custom_name' => (string)$r['custom_name'],
|
||
'extras' => $extras,
|
||
'images' => $images,
|
||
'cover_image' => !empty($images) ? $images[0] : ($fs && !empty($fs['image_url']) ? $fs['image_url'] : ''),
|
||
'remark' => (string)$r['remark'],
|
||
'is_current_cat' => ($preferCatId > 0 && $cid === $preferCatId) ? 1 : 0,
|
||
'time_text' => $r['updatetime'] ? date('Y-m-d', (int)$r['updatetime']) : '',
|
||
];
|
||
}
|
||
|
||
if ($preferCatId > 0) {
|
||
usort($list, function ($a, $b) {
|
||
if ($b['is_current_cat'] !== $a['is_current_cat']) {
|
||
return $b['is_current_cat'] <=> $a['is_current_cat'];
|
||
}
|
||
return $b['id'] <=> $a['id'];
|
||
});
|
||
}
|
||
|
||
$this->success('', ['list' => $list]);
|
||
}
|
||
|
||
/**
|
||
* 保存当前商品配置为常用商品模板
|
||
* @ApiMethod (POST)
|
||
*/
|
||
public function saveGoodsTemplate()
|
||
{
|
||
$uid = $this->uid();
|
||
$tplId = (int)$this->request->post('id', 0);
|
||
$tplName = mb_substr(trim((string)$this->request->post('tpl_name', '')), 0, 80);
|
||
$catId = max(0, (int)$this->request->post('cat_id', 0));
|
||
$catName = mb_substr(trim((string)$this->request->post('cat_name', '')), 0, 50);
|
||
$serviceId = max(0, (int)$this->request->post('service_id', 0));
|
||
$itemName = mb_substr(trim((string)$this->request->post('item_name', '')), 0, 80);
|
||
$specId = max(0, (int)$this->request->post('spec_id', 0));
|
||
$specName = mb_substr(trim((string)$this->request->post('spec_name', '')), 0, 80);
|
||
$unit = mb_substr(trim((string)$this->request->post('unit', '个')), 0, 20) ?: '个';
|
||
$num = max(1, min(999, (int)$this->request->post('num', 1)));
|
||
$serviceType = mb_substr(trim((string)$this->request->post('service_type', 'install')), 0, 40) ?: 'install';
|
||
$serviceTypeName = mb_substr(trim((string)$this->request->post('service_type_name', '安装')), 0, 40) ?: '安装';
|
||
$isCustom = (int)$this->request->post('is_custom', 0) ? 1 : 0;
|
||
$customName = mb_substr(trim((string)$this->request->post('custom_name', '')), 0, 80);
|
||
$remark = mb_substr(trim((string)$this->request->post('remark', '')), 0, 255);
|
||
|
||
if ($isCustom && $customName !== '' && $itemName === '') {
|
||
$itemName = $customName;
|
||
}
|
||
if ($itemName === '') {
|
||
$this->error('请先选择商品后再保存为模板');
|
||
}
|
||
if ($tplName === '') {
|
||
$tplName = $itemName . ($specName !== '' && $specName !== $itemName ? (' · ' . $specName) : '');
|
||
}
|
||
|
||
$rawExtras = $this->postMixed('extras', '');
|
||
$extrasArr = [];
|
||
if (!empty($rawExtras)) {
|
||
$decEx = is_array($rawExtras) ? $rawExtras : json_decode(htmlspecialchars_decode((string)$rawExtras), true);
|
||
if (is_array($decEx)) {
|
||
foreach ($decEx as $ex) {
|
||
if (is_array($ex) && !empty($ex['name'])) {
|
||
$extrasArr[] = [
|
||
'id' => (int)($ex['id'] ?? 0),
|
||
'name' => mb_substr(trim((string)$ex['name']), 0, 50),
|
||
'unit' => mb_substr(trim((string)($ex['unit'] ?? '份')), 0, 20),
|
||
'price' => (float)($ex['price'] ?? 0),
|
||
'num' => max(0, (int)($ex['num'] ?? 0)),
|
||
];
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
$rawImages = $this->postMixed('images', '');
|
||
$imagesArr = [];
|
||
if (!empty($rawImages)) {
|
||
$decIm = is_array($rawImages) ? $rawImages : json_decode(htmlspecialchars_decode((string)$rawImages), true);
|
||
if (is_array($decIm)) {
|
||
foreach ($decIm as $im) {
|
||
if (is_string($im) && trim($im) !== '' && substr(strtolower(trim($im)), -4) !== '.svg') {
|
||
$imagesArr[] = trim($im);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
$imagesArr = array_slice(array_values(array_unique($imagesArr)), 0, 9);
|
||
|
||
$now = time();
|
||
$payload = [
|
||
'tpl_name' => $tplName,
|
||
'cat_id' => $catId,
|
||
'cat_name' => $catName,
|
||
'service_id' => $serviceId,
|
||
'item_name' => $itemName,
|
||
'spec_id' => $specId,
|
||
'spec_name' => $specName,
|
||
'unit' => $unit,
|
||
'num' => $num,
|
||
'service_type' => $serviceType,
|
||
'service_type_name' => $serviceTypeName,
|
||
'is_custom' => $isCustom,
|
||
'custom_name' => $customName,
|
||
'extras_json' => json_encode($extrasArr, JSON_UNESCAPED_UNICODE),
|
||
'images_json' => json_encode($imagesArr, JSON_UNESCAPED_UNICODE),
|
||
'remark' => $remark,
|
||
'updatetime' => $now,
|
||
];
|
||
|
||
if ($tplId > 0) {
|
||
$own = Db::name('user_goods_template')->where('id', $tplId)->where('user_id', $uid)->find();
|
||
if (!$own) {
|
||
$this->error('模板不存在或无权修改');
|
||
}
|
||
Db::name('user_goods_template')->where('id', $tplId)->where('user_id', $uid)->update($payload);
|
||
} else {
|
||
$cnt = (int)Db::name('user_goods_template')->where('user_id', $uid)->count();
|
||
if ($cnt >= 50) {
|
||
$this->error('最多可保存 50 个商品模板,请先清理旧模板');
|
||
}
|
||
$payload['user_id'] = $uid;
|
||
$payload['createtime'] = $now;
|
||
$tplId = (int)Db::name('user_goods_template')->insertGetId($payload);
|
||
}
|
||
|
||
$this->success('商品模板保存成功', ['id' => $tplId, 'tpl_name' => $tplName]);
|
||
}
|
||
|
||
/**
|
||
* 删除本人保存的商品模板
|
||
* @ApiMethod (POST)
|
||
*/
|
||
public function deleteGoodsTemplate()
|
||
{
|
||
$uid = $this->uid();
|
||
$id = (int)$this->request->post('id', 0);
|
||
if ($id <= 0) {
|
||
$this->error('参数错误');
|
||
}
|
||
$deleted = Db::name('user_goods_template')->where('id', $id)->where('user_id', $uid)->delete();
|
||
if (!$deleted) {
|
||
$this->error('模板不存在或无权删除');
|
||
}
|
||
$this->success('商品模板已删除');
|
||
}
|
||
|
||
/**
|
||
* 获取常用服务备注模板列表(系统公共模板 + 当前登录用户自建模板)
|
||
*/
|
||
public function remarkTemplates()
|
||
{
|
||
$uid = $this->uid();
|
||
$rows = Db::name('remark_template')
|
||
->where('user_id', 'in', [0, $uid])
|
||
->order('user_id desc, weigh desc, id desc')
|
||
->limit(50)
|
||
->select();
|
||
|
||
$list = [];
|
||
foreach ($rows as $r) {
|
||
$list[] = [
|
||
'id' => (int)$r['id'],
|
||
'content' => (string)$r['content'],
|
||
'is_mine' => ((int)$r['user_id'] === $uid && $uid > 0) ? 1 : 0,
|
||
];
|
||
}
|
||
$this->success('', ['list' => $list]);
|
||
}
|
||
|
||
/**
|
||
* 新增保存个人常用服务备注模板
|
||
* @ApiMethod (POST)
|
||
*/
|
||
public function saveRemarkTemplate()
|
||
{
|
||
$uid = $this->uid();
|
||
$content = mb_substr(trim((string)$this->request->post('content', '')), 0, 120);
|
||
if ($content === '') {
|
||
$this->error('请输入备注模板内容');
|
||
}
|
||
$exists = Db::name('remark_template')
|
||
->where('user_id', 'in', [0, $uid])
|
||
->where('content', $content)
|
||
->find();
|
||
if ($exists) {
|
||
$this->error('该常用备注已存在');
|
||
}
|
||
$cnt = (int)Db::name('remark_template')->where('user_id', $uid)->count();
|
||
if ($cnt >= 20) {
|
||
$this->error('个人常用备注最多保存 20 条');
|
||
}
|
||
$id = (int)Db::name('remark_template')->insertGetId([
|
||
'user_id' => $uid,
|
||
'content' => $content,
|
||
'weigh' => 100,
|
||
'createtime' => time(),
|
||
]);
|
||
$this->success('常用备注已添加', [
|
||
'id' => $id,
|
||
'content' => $content,
|
||
'is_mine' => 1,
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* 删除个人自建的常用服务备注模板(禁止删除 user_id=0 的系统模板)
|
||
* @ApiMethod (POST)
|
||
*/
|
||
public function deleteRemarkTemplate()
|
||
{
|
||
$uid = $this->uid();
|
||
$id = (int)$this->request->post('id', 0);
|
||
if ($id <= 0) {
|
||
$this->error('参数错误');
|
||
}
|
||
$deleted = Db::name('remark_template')
|
||
->where('id', $id)
|
||
->where('user_id', $uid)
|
||
->delete();
|
||
if (!$deleted) {
|
||
$this->error('该备注不存在或不支持删除');
|
||
}
|
||
$this->success('常用备注已删除');
|
||
}
|
||
|
||
}
|