model = PackageModel::class; $this->selectField = [ 'id', 'name', 'cover', 'subtitle', 'remark', 'original_amount', 'package_amount', 'is_hot', 'sort', 'status', 'created_at', 'updated_at', ]; $this->queryField = [ 'name' => 'like', 'status' => '=', 'is_hot' => '=', ]; $this->orderBy = ['name' => 'sort', 'sort' => 'asc']; $this->media = MediaUrlService::getInstance(); $this->quote = PackageQuoteService::getInstance(); $this->price = PriceService::getInstance(); } public function list(): array { $result = $this->getPageList(); $ids = array_column($result['items'], 'id'); $counts = PackageItemModel::whereIn('package_id', $ids) ->where('deleted_at', 0) ->selectRaw('package_id, count(*) as total') ->groupBy('package_id') ->get() ->keyBy('package_id'); foreach ($result['items'] as &$item) { $item['cover'] = $this->media->toPublic($item['cover'] ?? ''); $item['item_count'] = (int) ($counts[$item['id']]['total'] ?? 0); $item = $this->withAmountText($item); } unset($item); return $result; } public function option(): mixed { $this->optionField = ['id', 'name']; return $this->getOption(); } public function detail($id): mixed { $info = PackageModel::with([ 'items' => fn ($query) => $query->where('deleted_at', 0)->orderBy('sort')->orderBy('id'), 'items.catalogue', 'items.priceSheet', ])->where('id', $id)->where('deleted_at', 0)->first(); if (empty($info)) { $this->utils->errorThrow('套餐不存在'); } $info = $info->toArray(); $info['cover'] = $this->media->toPublic($info['cover'] ?? ''); foreach ($info['items'] as &$item) { $item['title'] = $item['catalogue']['title'] ?? ''; $item['cover'] = $this->media->toPublic($item['catalogue']['cover'] ?? ''); $item['specification'] = $item['price_sheet']['specification'] ?? ''; $item['dimension'] = $item['price_sheet']['dimension'] ?? ''; $item['unit_price_text'] = $this->price->centsToYuan((int) ($item['unit_price'] ?? 0)); } unset($item); return $this->withAmountText($info); } public function create($params): mixed { $row = $this->normalizeMain($params); $row['created_at'] = time(); $id = PackageModel::insertGetId($row); if (!empty($params['items']) && is_array($params['items'])) { $this->replaceItems((int) $id, $params['items'], $params); } return $this->detail($id); } public function update($id, $params): mixed { $row = $this->normalizeMain($params, (int) $id); $this->save($id, $row); return $this->detail($id); } public function delete($ids): mixed { $ids = (array) $ids; $now = time(); PackageModel::whereIn('id', $ids)->update(['deleted_at' => $now, 'updated_at' => $now]); PackageItemModel::whereIn('package_id', $ids)->update(['deleted_at' => $now, 'updated_at' => $now]); return true; } public function status($id, $status): mixed { return $this->save($id, ['status' => (int) $status]); } /** * 搭配搜索:商品封面 + 规格报价。 * 单价用和保存时同一套 resolveUnitPrice,没有数字报价的规格标成不可选,避免搜到却存不进去。 */ public function searchCatalog(): array { $keyword = trim((string) request()->input('keyword', '')); $limit = max(1, min(50, (int) request()->input('limit', 20))); $query = CatalogueModel::query() ->where('deleted_at', 0) ->with([ 'priceSheet' => fn ($q) => $q->where('deleted_at', 0)->orderBy('id'), ]); if ($keyword !== '') { $like = '%' . $keyword . '%'; $query->where(function ($inner) use ($like) { $inner->where('title', 'like', $like) ->orWhere('identifier', 'like', $like) ->orWhere('alias', 'like', $like); }); } $rows = $query->orderByDesc('id')->limit($limit)->get()->toArray(); $items = []; foreach ($rows as $row) { $sheets = []; foreach ($row['price_sheet'] ?? [] as $sheet) { $unit = $this->price->resolveUnitPrice((string) ($sheet['routine'] ?? ''), 'routine', 1); $sheets[] = [ 'id' => (int) ($sheet['id'] ?? 0), 'specification' => (string) ($sheet['specification'] ?? ''), 'dimension' => (string) ($sheet['dimension'] ?? ''), 'unit_price' => $unit, 'unit_price_text' => $this->price->centsToYuan($unit), 'selectable' => $unit > 0, ]; } $items[] = [ 'id' => (int) ($row['id'] ?? 0), 'title' => (string) ($row['title'] ?? ''), 'identifier' => (string) ($row['identifier'] ?? ''), 'cover' => $this->media->toPublic($row['cover'] ?? ''), 'sheets' => $sheets, ]; } return ['items' => $items]; } /** * 整表替换搭配明细,并按原价差额补套餐报价 */ public function saveItems(array $params): mixed { $id = (int) ($params['id'] ?? 0); if ($id <= 0) { $this->utils->errorThrow('请选择套餐'); } $pkg = PackageModel::where('id', $id)->where('deleted_at', 0)->first(); if (empty($pkg)) { $this->utils->errorThrow('套餐不存在'); } $this->replaceItems($id, $params['items'] ?? [], $params); return $this->detail($id); } /** * 清洗主表字段。package_amount 按元进来,转成分;没传报价时新建默认等于原价。 */ private function normalizeMain(array $params, int $id = 0): array { $row = [ 'updated_at' => time(), ]; if (array_key_exists('name', $params)) { $name = trim((string) $params['name']); if ($name === '') { $this->utils->errorThrow('请填写套餐名称'); } $row['name'] = $name; } elseif ($id === 0) { $this->utils->errorThrow('请填写套餐名称'); } if (array_key_exists('cover', $params)) { $row['cover'] = $this->media->firstOf($params['cover']); } foreach (['subtitle', 'remark'] as $field) { if (array_key_exists($field, $params)) { $row[$field] = (string) $params[$field]; } } foreach (['is_hot', 'sort', 'status'] as $field) { if (array_key_exists($field, $params)) { $row[$field] = (int) $params[$field]; } } if (array_key_exists('package_amount', $params)) { $row['package_amount'] = max(0, $this->price->yuanToCents($params['package_amount'])); } if ($id === 0 && !isset($row['package_amount'])) { $row['package_amount'] = 0; $row['original_amount'] = 0; } return $row; } /** * 校验规格、写入明细、重算原价。前端传了 package_amount(元)就用它,否则按差额补。 */ private function replaceItems(int $packageId, array $items, array $params): void { $pkg = PackageModel::where('id', $packageId)->where('deleted_at', 0)->first(); $oldOriginal = (int) ($pkg['original_amount'] ?? 0); $oldQuote = (int) ($pkg['package_amount'] ?? 0); $rows = []; $now = time(); $sort = 0; $newOriginal = 0; foreach ($items as $item) { if (!is_array($item)) { continue; } $catalogueId = (int) ($item['catalogue_id'] ?? 0); $sheetId = (int) ($item['price_sheet_id'] ?? 0); $qty = max(1, (int) ($item['quantity'] ?? 1)); if ($catalogueId <= 0 || $sheetId <= 0) { $this->utils->errorThrow('每件商品都必须选择规格'); } $sheet = PriceSheetModel::where('id', $sheetId) ->where('catalogue_id', $catalogueId) ->where('deleted_at', 0) ->first(); if (empty($sheet)) { $this->utils->errorThrow('规格不存在或不属于该商品'); } $unit = $this->price->resolveUnitPrice( (string) ($sheet['routine'] ?? ''), (string) ($item['material_key'] ?? 'routine'), 1 ); if ($unit <= 0) { $this->utils->errorThrow('规格缺少有效报价'); } $newOriginal += $unit * $qty; $rows[] = [ 'package_id' => $packageId, 'catalogue_id' => $catalogueId, 'price_sheet_id' => $sheetId, 'material_key' => (string) ($item['material_key'] ?? 'routine'), 'quantity' => $qty, 'unit_price' => $unit, 'sort' => (int) ($item['sort'] ?? $sort), 'remark' => (string) ($item['remark'] ?? ''), 'created_at' => $now, 'updated_at' => $now, 'deleted_at' => 0, ]; $sort++; } $quote = array_key_exists('package_amount', $params) ? max(0, $this->price->yuanToCents($params['package_amount'])) : $this->quote->applyOriginalDiff($oldOriginal, $newOriginal, $oldQuote); DB::connection('business')->transaction(function () use ($packageId, $rows, $now, $newOriginal, $quote) { PackageItemModel::where('package_id', $packageId)->where('deleted_at', 0)->update([ 'deleted_at' => $now, 'updated_at' => $now, ]); if (!empty($rows)) { PackageItemModel::insert($rows); } PackageModel::where('id', $packageId)->update([ 'original_amount' => $newOriginal, 'package_amount' => $quote, 'updated_at' => $now, ]); }); } /** * 补原价/套餐价/优惠的元文字段 */ private function withAmountText(array $row): array { $original = (int) ($row['original_amount'] ?? 0); $quote = (int) ($row['package_amount'] ?? 0); $row['discount_amount'] = max(0, $original - $quote); return $this->quote->withText($row); } }