success('', ['list' => OrderStatus::all()]); } /** * 物流单号智能识别物流公司(支持线上快递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('请输入正确的物流单号'); } $codeMap = [ 'shunfeng' => '顺丰速运', 'debangwuliu' => '德邦快递', 'debangkuaidi' => '德邦快递', 'jd' => '京东快递', 'kuayue' => '跨越速运', 'shentong' => '申通快递', 'yuantong' => '圆通速递', 'zhongtong' => '中通快递', 'yunda' => '韵达', 'huitongkuaidi' => '百世快递', 'baishiwuliu' => '百世快递', 'tiantian' => '天天快递', 'ems' => '邮政特快专递', 'youzhengguonei' => '中国邮政', 'youzhengbk' => '中国邮政', 'zhaijisong' => '宅急送', 'jtexpress' => '极兔速递', 'annengwuliu' => '安能物流', ]; $companyName = ''; // 1. 优先匹配高确定性的标准物流单号前缀规则 if (strpos($no, 'SF') === 0) { $companyName = '顺丰速运'; } elseif (strpos($no, 'DP') === 0 || strpos($no, 'DB') === 0) { $companyName = '德邦快递'; } elseif (strpos($no, 'JD') === 0 || strpos($no, 'KK') === 0) { $companyName = '京东快递'; } elseif (strpos($no, 'KY') === 0) { $companyName = '跨越速运'; } elseif (strpos($no, 'YT') === 0) { $companyName = '圆通速递'; } elseif (strpos($no, 'ST') === 0 || strpos($no, '77') === 0) { $companyName = '申通快递'; } elseif (strpos($no, 'ZT') === 0 || preg_match('/^(73|75|76|78)\d{10,12}$/', $no)) { $companyName = '中通快递'; } elseif (strpos($no, 'YD') === 0 || preg_match('/^(31|43|46)\d{11,13}$/', $no)) { $companyName = '韵达'; } elseif (strpos($no, 'BS') === 0 || strpos($no, 'HT') === 0 || preg_match('/^55\d{11,13}$/', $no)) { $companyName = '百世快递'; } elseif (strpos($no, 'TT') === 0 || preg_match('/^66\d{10,12}$/', $no)) { $companyName = '天天快递'; } elseif (preg_match('/^(EMS|EA|EB|EC|ED|EE|EF|10|11)\d{7,11}/', $no)) { $companyName = '邮政特快专递'; } elseif (preg_match('/^(98|99|YZ)\d{9,13}$/', $no)) { $companyName = '中国邮政'; } elseif (strpos($no, 'ZJS') === 0) { $companyName = '宅急送'; } elseif (strpos($no, 'JT') === 0) { $companyName = '极兔速递'; } elseif (strpos($no, 'ANE') === 0) { $companyName = '安能物流'; } // 2. 若前缀未命中,尝试快递100智能单号识别接口(1.5秒超时保护) if ($companyName === '') { try { $ctx = stream_context_create([ 'http' => ['timeout' => 1.5, 'ignore_errors' => true], 'ssl' => ['verify_peer' => false, 'verify_peer_name' => false], ]); $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 (\Exception $e) { \think\Log::warning('[Order::detectExpress] ' . $e->getMessage()); } } // 3. 常见纯数字位数兜底识别 if ($companyName === '') { if (preg_match('/^\d{15}$/', $no)) { $companyName = '德邦快递'; } elseif (preg_match('/^\d{12}$/', $no)) { $companyName = '顺丰速运'; } elseif (preg_match('/^\d{13}$/', $no)) { $companyName = '圆通速递'; } elseif (preg_match('/^\d{14}$/', $no)) { $companyName = '中通快递'; } else { $companyName = '德邦快递'; } } $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')); $goodsImages = $this->request->post('goods_images', ''); // 支持多商品组合 (goods_list / items) $goodsListRaw = $this->request->post('goods_list', $this->request->post('items', '', null), null); $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); if ($contact && $mobile && $detailAddr) { $addressId = Db::name('address')->insertGetId([ 'user_id' => $this->uid(), 'contact' => $contact, 'mobile' => $mobile, 'province' => $province, 'city' => $city, 'district' => $district, 'address' => $detailAddr, 'floor' => $floor, 'has_elevator' => $hasElevator, 'createtime' => time(), 'updatetime' => time(), ]); $address = Db::name('address')->where('id', $addressId)->find(); } } if (!$address) { $this->error('请选择或填写完整服务地址'); } if ($appointTime > 0 && $appointTime < time()) { $this->error('预约时间不能早于当前时间'); } // 多商品组合名称、总件数与图片汇总 $serviceName = $service ? $service['name'] : '同城综合上门服务'; $catId = $service ? (int)$service['cat_id'] : 1; $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' => '按面积计价', ]; 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']; } $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'], '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()); } } $this->success('下单成功', [ 'order_id' => $id, 'order_no' => $orderNo, 'amount' => $amount, ]); } /** * 支付(联调模拟支付,pay_type=mock;上线换微信支付回调) * @ApiMethod (POST) */ public function pay() { $orderId = (int)$this->request->post('order_id'); $payType = $this->request->post('pay_type', 'mock'); $order = $this->ownOrder($orderId, 'user'); if ((int)$order['pay_status'] === 1) { $this->error('订单已支付,请勿重复支付'); } if ((int)$order['status'] !== OrderStatus::PENDING_PAY) { $this->error('订单状态不允许支付'); } $now = time(); $nextStatus = OrderStatus::PENDING_ACCEPT; Db::startTrans(); try { $lockedOrder = Db::name('order')->where('id', $orderId)->lock(true)->find(); if (!$lockedOrder) { throw new \InvalidArgumentException('订单不存在'); } if ((int)$lockedOrder['pay_status'] === 1) { throw new \InvalidArgumentException('订单已支付,请勿重复支付'); } if ((int)$lockedOrder['status'] !== OrderStatus::PENDING_PAY) { throw new \InvalidArgumentException('订单状态不允许支付'); } // 兼容创单阶段已加锁核销的优惠券,防止重复报错或漏锁 if ((int)$lockedOrder['coupon_id'] > 0) { $uc = Db::name('user_coupon') ->where('id', (int)$lockedOrder['coupon_id']) ->where('user_id', (int)$lockedOrder['user_id']) ->lock(true) ->find(); if (!$uc) { throw new \InvalidArgumentException('订单关联的优惠券不存在'); } if ($uc['status'] === 'used' && (int)$uc['order_id'] === $orderId) { // 创单已锁定本单卡券 } elseif ($uc['status'] === 'unused') { $affectedUc = Db::name('user_coupon') ->where('id', (int)$uc['id']) ->where('user_id', (int)$lockedOrder['user_id']) ->where('status', 'unused') ->update([ 'status' => 'used', 'order_id' => $orderId, 'use_time' => $now, ]); if (!$affectedUc) { throw new \InvalidArgumentException('优惠券状态异常,请重新下单'); } } else { throw new \InvalidArgumentException('该优惠券已被其他订单使用或已过期'); } } Db::name('order')->where('id', $orderId)->update([ 'pay_status' => 1, 'pay_type' => $payType, 'pay_time' => $now, 'transaction_id' => 'MOCK' . date('YmdHis', $now) . mt_rand(1000, 9999), 'updatetime' => $now, ]); $nextStatus = $lockedOrder['price_type'] === 'quote' ? OrderStatus::QUOTING : OrderStatus::PENDING_ACCEPT; $nextRemark = $nextStatus === OrderStatus::QUOTING ? '支付成功,订单已进入报价大厅,等待师傅报价' : '支付成功,订单已进入接单大厅'; $this->transit($lockedOrder, $nextStatus, $nextRemark, [], 'user', $this->uid()); $this->addLog($orderId, $nextStatus, $nextStatus === OrderStatus::QUOTING ? '订单进入师傅报价大厅' : '订单进入师傅接单大厅', 'system', 0); 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::pay] ' . $e->getMessage()); $this->error('支付失败:' . $e->getMessage()); return; } // 订单广播通知:进入接单/报价大厅后,通知周边在线认证师傅(排除下单人本人) if (in_array($nextStatus, [OrderStatus::PENDING_ACCEPT, OrderStatus::QUOTING], true)) { try { $masters = Db::name('master') ->where('verify_status', 'pass') ->where('work_status', 'on') ->where('user_id', '<>', (int)$order['user_id']) ->limit(20) ->select(); foreach ($masters as $m) { $this->notify('master', (int)$m['id'], '新工单就绪通知', '同城有新工单【' . $order['order_no'] . '】进入接单大厅,请尽快前往抢单!', 'order', $orderId); } } catch (\Exception $e) { \think\Log::error('[Order::pay broadcast] ' . $e->getMessage()); } } $this->success('支付成功'); } /** * 我的订单列表 */ /** * 用户在线支付施工增项补差价 * @ApiMethod (POST) */ public function payDiff() { $orderId = (int)$this->request->post('order_id'); $order = $this->ownOrder($orderId, 'user'); if ((int)$order['diff_status'] !== 1 || (float)$order['diff_amount'] <= 0) { $this->error('当前订单无待支付的增项补差价'); } $diffAmount = (float)$order['diff_amount']; $newPayAmount = round((float)$order['pay_amount'] + $diffAmount, 2); Db::startTrans(); try { $lockedOrder = Db::name('order')->where('id', $orderId)->lock(true)->find(); if (!$lockedOrder || (int)$lockedOrder['diff_status'] !== 1 || (float)$lockedOrder['diff_amount'] <= 0) { throw new \InvalidArgumentException('当前订单无待支付的增项补差价'); } $diffAmount = (float)$lockedOrder['diff_amount']; $newPayAmount = round((float)$lockedOrder['pay_amount'] + $diffAmount, 2); Db::name('order')->where('id', $orderId)->update([ 'diff_status' => 2, 'pay_amount' => $newPayAmount, 'updatetime' => time(), ]); $this->addLog($orderId, (int)$lockedOrder['status'], '用户在线支付施工增项补差价 ¥' . $diffAmount, 'user', $this->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::payDiff] ' . $e->getMessage()); $this->error('支付失败:' . $e->getMessage()); return; } if ((int)$order['master_id'] > 0) { $m = Db::name('master')->where('id', $order['master_id'])->find(); if ($m) { $this->notify('master', (int)$m['id'], '客户已支付增项补差价', '工单 ' . $order['order_no'] . ' 客户已支付增项补差价 ¥' . $diffAmount, 'order', $orderId); } } $this->success('增项补差价支付成功!', ['pay_amount' => $newPayAmount]); } 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'); $order = $this->ownOrder($orderId, 'user'); if ($order['price_type'] !== 'quote') { $this->error('该订单不是师傅报价订单'); } if ((int)$order['status'] !== OrderStatus::QUOTING) { $this->error('当前状态(' . OrderStatus::label((int)$order['status']) . ')不可选择报价'); } if ((int)$order['master_id'] > 0) { $this->error('已选定师傅,请勿重复选择'); } $quote = Db::name('order_quote')->where('id', $quoteId)->where('order_id', $orderId)->find(); if (!$quote) { $this->error('报价不存在'); } if ($quote['status'] !== 'wait') { $this->error('该报价已失效,请选择其他师傅'); } if ((int)$quote['expire_time'] > 0 && (int)$quote['expire_time'] < time()) { Db::name('order_quote')->where('id', $quoteId)->update(['status' => 'expired']); $this->error('该报价已过期,请选择其他师傅'); } $master = Db::name('master')->where('id', (int)$quote['master_id'])->find(); if (!$master) { $this->error('师傅信息不存在'); } $quoteAmount = round((float)$quote['amount'], 2); $payAmount = max(0, round($quoteAmount - (float)$order['discount_amount'], 2)); $alreadyPaid = (int)$order['pay_status'] === 1 ? (float)$order['pay_amount'] : 0.0; $needDiff = max(0, round($payAmount - $alreadyPaid, 2)); $payType = $this->request->post('pay_type', 'wechat'); $remark = '客户已选择 ' . $master['realname'] . ' 的报价 ¥' . $quoteAmount; if ($needDiff > 0) { $remark .= '(补付差额 ¥' . $needDiff . ')'; } $remark .= ',等待师傅联系'; Db::startTrans(); try { $lockedOrder = Db::name('order')->where('id', $orderId)->lock(true)->find(); if (!$lockedOrder || (int)$lockedOrder['status'] !== OrderStatus::QUOTING || (int)$lockedOrder['master_id'] !== 0) { throw new \InvalidArgumentException('该工单已选定师傅或状态已变更,请刷新重试'); } $quoteAmount = round((float)$quote['amount'], 2); $payAmount = max(0, round($quoteAmount - (float)$lockedOrder['discount_amount'], 2)); $alreadyPaid = (int)$lockedOrder['pay_status'] === 1 ? (float)$lockedOrder['pay_amount'] : 0.0; $needDiff = max(0, round($payAmount - $alreadyPaid, 2)); $updateData = [ 'master_id' => (int)$quote['master_id'], 'accept_time' => time(), 'base_amount' => $quoteAmount, 'floor_amount' => 0, 'urgent_amount' => 0, 'total_amount' => $quoteAmount, 'pay_amount' => $payAmount, 'pay_status' => 1, ]; if ($needDiff > 0) { $updateData['pay_type'] = $payType; $updateData['pay_time'] = time(); } $this->transit($lockedOrder, OrderStatus::ACCEPTED, $remark, $updateData, 'user', $this->uid()); Db::name('order_quote')->where('id', $quoteId)->update(['status' => 'accept']); Db::name('order_quote')->where('order_id', $orderId)->where('id', '<>', $quoteId) ->where('status', 'wait')->update(['status' => 'reject']); $this->notify('user', (int)$lockedOrder['user_id'], '已选定服务师傅', $remark, 'order', $orderId); $this->notify('master', (int)$master['id'], '您的报价已被选中', '订单 ' . $lockedOrder['order_no'] . ' 客户已选择您的报价 ¥' . $quoteAmount . ',请尽快联系客户', '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::chooseQuote] ' . $e->getMessage()); $this->error('选择报价失败:' . $e->getMessage()); return; } $this->success('已选定师傅并完成支付托管,请等待师傅联系', [ 'pay_amount' => $payAmount, 'need_diff' => $needDiff, 'already_pay' => $alreadyPaid, ]); } /** * 报价订单:报价列表(详情页轮询/刷新使用) */ 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('该订单已开具发票,如需取消退款请联系客服办理发票冲红'); } $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'] = $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']; $rate = 10.00; $commission = round($payAmount * $rate / 100, 2); $settleAmount = round($payAmount - $commission, 2); 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']; $commission = round($payAmount * $rate / 100, 2); $settleAmount = round($payAmount - $commission, 2); $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']; $masterId = (int)$order['master_id']; $payAmount = (float)$order['pay_amount']; $rate = 10.00; $commission = round($payAmount * $rate / 100, 2); $settleAmount = round($payAmount - $commission, 2); Db::startTrans(); try { 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); $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 === '') { 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 (substr(strtolower($rawIm), -4) === '.svg' || strpos($rawIm, '/gallery/') !== false || strpos($rawIm, '/goods/') !== false) { 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' => 'history', 'can_delete' => 0, 'time_text' => $ho['createtime'] ? date('Y-m-d', (int)$ho['createtime']) : '', ]; } } $this->success('', [ 'cats' => $cats, 'list' => $list, ]); } /** * 上传保存商品图至个人专属商品图库 * @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->request->post('images', '', null); $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)); } } } $validUrls = []; foreach ($imgList as $u) { if (!is_string($u)) { continue; } $u = trim($u); if ($u === '' || strlen($u) > 255 || substr(strtolower($u), -4) === '.svg') { 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->error('请上传有效的商品图片'); } if ($title === '' && $catId > 0) { $cName = (string)Db::name('service_cat')->where('id', $catId)->value('name'); $title = $cName !== '' ? ($cName . '商品实拍') : '常用商品图'; } elseif ($title === '') { $title = '常用商品图'; } $now = time(); $saved = []; 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, ]); $saved[] = [ 'id' => $newId, 'cat_id' => $catId, 'title' => $title, 'image_url' => $fullUrl, 'source' => 'library', 'can_delete' => 1, ]; } $this->success('已存入商品图库', ['list' => $saved]); } /** * 删除本人商品图库中的图片(严格校验 user_id 防越权) * @ApiMethod (POST) */ public function deleteGoodsGallery() { $uid = $this->uid(); $singleId = (int)$this->request->post('id', 0); $rawIds = $this->request->post('ids', '', null); $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->request->post('extras', '', null); $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->request->post('images', '', null); $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('常用备注已删除'); } }