From 4ddf5e3c5f648d3af6e69dca3415efe4147c09d1 Mon Sep 17 00:00:00 2001 From: LQ Date: Thu, 20 Aug 2026 19:16:49 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E8=8B=A5=E5=B9=B2=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/Api/AfterSaleController.php | 38 +++ .../Controllers/Api/CatalogueController.php | 25 +- .../Controllers/Api/FactoryInfoController.php | 19 ++ .../Controllers/Api/FeedbackController.php | 34 +++ app/Http/Controllers/Api/ListController.php | 9 + .../Controllers/Api/PriceSheetController.php | 2 +- app/Http/Controllers/Api/WxAppController.php | 3 +- app/Http/Controllers/Wx/AddressController.php | 39 +++ .../Controllers/Wx/AfterSaleController.php | 30 +++ .../Controllers/Wx/ColorcardController.php | 39 +++ app/Http/Controllers/Wx/FactoryController.php | 30 +++ .../Controllers/Wx/FavoriteController.php | 30 +++ .../Controllers/Wx/FeedbackController.php | 30 +++ app/Http/Controllers/Wx/ListController.php | 9 + app/Http/Controllers/Wx/UserController.php | 9 + app/Models/business/AfterSaleModel.php | 32 +++ app/Models/business/FavoriteModel.php | 21 ++ app/Models/business/FeedbackModel.php | 25 ++ app/Models/business/WxAddressModel.php | 15 ++ app/Service/WxAppConfigService.php | 3 +- app/Service/business/AfterSaleService.php | 61 +++++ app/Service/business/CatalogueService.php | 238 +++++++++++++++++- app/Service/business/FactoryInfoService.php | 80 ++++++ app/Service/business/FeedbackService.php | 56 +++++ app/Service/business/ListService.php | 12 + app/Service/business/OrderCoreService.php | 62 ++++- app/Service/business/OrderService.php | 10 + app/Service/business/PriceSheetService.php | 5 +- app/Service/business/StockService.php | 67 +++++ .../business/WxTemplateSchemaService.php | 9 +- app/Service/common/ExcelCsvService.php | 142 +++++++++++ app/Service/wx/WxAddressService.php | 117 +++++++++ app/Service/wx/WxAfterSaleService.php | 82 ++++++ app/Service/wx/WxColorcardService.php | 76 ++++++ app/Service/wx/WxFactoryService.php | 74 ++++++ app/Service/wx/WxFavoriteService.php | 73 ++++++ app/Service/wx/WxFeedbackService.php | 41 +++ app/Service/wx/WxListService.php | 12 + app/Service/wx/WxMiniService.php | 28 +++ app/Service/wx/WxPackageService.php | 7 + app/Service/wx/WxProductService.php | 11 +- app/Service/wx/WxThemeService.php | 15 ++ app/Service/wx/WxUserCenterService.php | 15 ++ config/lgp_menu.php | 2 + config/wx_page_presets.php | 10 +- database/sql/2026-08-19/01_gap_features.sql | 167 ++++++++++++ public/nl_admin.sql | 2 + routes/api.php | 10 +- 48 files changed, 1908 insertions(+), 18 deletions(-) create mode 100644 app/Http/Controllers/Api/AfterSaleController.php create mode 100644 app/Http/Controllers/Api/FeedbackController.php create mode 100644 app/Http/Controllers/Wx/AddressController.php create mode 100644 app/Http/Controllers/Wx/AfterSaleController.php create mode 100644 app/Http/Controllers/Wx/ColorcardController.php create mode 100644 app/Http/Controllers/Wx/FactoryController.php create mode 100644 app/Http/Controllers/Wx/FavoriteController.php create mode 100644 app/Http/Controllers/Wx/FeedbackController.php create mode 100644 app/Models/business/AfterSaleModel.php create mode 100644 app/Models/business/FavoriteModel.php create mode 100644 app/Models/business/FeedbackModel.php create mode 100644 app/Models/business/WxAddressModel.php create mode 100644 app/Service/business/AfterSaleService.php create mode 100644 app/Service/business/FeedbackService.php create mode 100644 app/Service/business/StockService.php create mode 100644 app/Service/common/ExcelCsvService.php create mode 100644 app/Service/wx/WxAddressService.php create mode 100644 app/Service/wx/WxAfterSaleService.php create mode 100644 app/Service/wx/WxColorcardService.php create mode 100644 app/Service/wx/WxFactoryService.php create mode 100644 app/Service/wx/WxFavoriteService.php create mode 100644 app/Service/wx/WxFeedbackService.php create mode 100644 database/sql/2026-08-19/01_gap_features.sql diff --git a/app/Http/Controllers/Api/AfterSaleController.php b/app/Http/Controllers/Api/AfterSaleController.php new file mode 100644 index 00000000..5ab59da2 --- /dev/null +++ b/app/Http/Controllers/Api/AfterSaleController.php @@ -0,0 +1,38 @@ +service = AfterSaleService::getInstance(); + } + + /** + * 审核售后 + * @Method POST + */ + public function audit(): JsonResponse + { + $pass = (int) request()->post('status', 1) === 1; + return jok( + $this->service->audit( + (int) request()->post('id', 0), + $pass, + (string) request()->post('admin_remark', '') + ), + $pass ? '已通过' : '已拒绝' + ); + } +} diff --git a/app/Http/Controllers/Api/CatalogueController.php b/app/Http/Controllers/Api/CatalogueController.php index 417f007d..95a5c4ff 100644 --- a/app/Http/Controllers/Api/CatalogueController.php +++ b/app/Http/Controllers/Api/CatalogueController.php @@ -18,7 +18,7 @@ class CatalogueController extends BaseController $this->service = CatalogueService::getInstance(); $this->insertField = ['title', 'category_id', 'cover', 'identifier']; $this->updateField = ['id', 'title', 'category_id', 'cover', 'identifier']; - $this->notRequest = ['alias', 'pdf', 'price', 'status']; + $this->notRequest = ['alias', 'pdf', 'video', 'price', 'status']; } /** @@ -32,4 +32,27 @@ class CatalogueController extends BaseController $params = $this->checkRequiredFields(request()->post()); return jok($this->service->status($params['id'], $params['status']), '操作成功'); } + + /** + * 导出商品表(结构化数据,前端 ExcelJS 画 xlsx) + * @Method GET + */ + public function export(): JsonResponse + { + return jok($this->service->exportExcel()); + } + + /** + * 导入商品表:前端已把 xlsx 拆成 rows + * @Method POST + */ + public function import(): JsonResponse + { + $rows = request()->post('rows', []); + $priceSheets = request()->post('price_sheets', []); + return jok($this->service->importRows( + is_array($rows) ? $rows : [], + is_array($priceSheets) ? $priceSheets : [], + ), '导入完成'); + } } diff --git a/app/Http/Controllers/Api/FactoryInfoController.php b/app/Http/Controllers/Api/FactoryInfoController.php index 529d1209..55db11c2 100644 --- a/app/Http/Controllers/Api/FactoryInfoController.php +++ b/app/Http/Controllers/Api/FactoryInfoController.php @@ -32,4 +32,23 @@ class FactoryInfoController extends BaseController $params = $this->checkRequiredFields(request()->post()); return jok($this->service->status($params['id'], $params['status']), '操作成功'); } + + /** + * 导出工厂表(结构化数据,前端 ExcelJS 画 xlsx) + * @Method GET + */ + public function export(): JsonResponse + { + return jok($this->service->exportExcel()); + } + + /** + * 导入工厂表:前端已把 xlsx 拆成 rows + * @Method POST + */ + public function import(): JsonResponse + { + $rows = request()->post('rows', []); + return jok($this->service->importRows(is_array($rows) ? $rows : []), '导入完成'); + } } diff --git a/app/Http/Controllers/Api/FeedbackController.php b/app/Http/Controllers/Api/FeedbackController.php new file mode 100644 index 00000000..e223bccb --- /dev/null +++ b/app/Http/Controllers/Api/FeedbackController.php @@ -0,0 +1,34 @@ +service = FeedbackService::getInstance(); + $this->updateField = ['id', 'reply']; + } + + /** + * 回复 + * @Method POST + */ + public function reply(): JsonResponse + { + return jok( + $this->service->reply((int) request()->post('id', 0), (string) request()->post('reply', '')), + '已回复' + ); + } +} diff --git a/app/Http/Controllers/Api/ListController.php b/app/Http/Controllers/Api/ListController.php index a5f5429c..850c7199 100644 --- a/app/Http/Controllers/Api/ListController.php +++ b/app/Http/Controllers/Api/ListController.php @@ -58,4 +58,13 @@ class ListController extends BaseController { return jok($this->service->deleteItem(request()->post('ids', [])), '删除成功'); } + + /** + * 导出清单报价(结构化数据 + 分享文本) + * @Method GET + */ + public function exportQuote(): JsonResponse + { + return jok($this->service->exportQuote((int) request()->get('id', 0))); + } } diff --git a/app/Http/Controllers/Api/PriceSheetController.php b/app/Http/Controllers/Api/PriceSheetController.php index 47b0c9d8..0269f891 100644 --- a/app/Http/Controllers/Api/PriceSheetController.php +++ b/app/Http/Controllers/Api/PriceSheetController.php @@ -18,7 +18,7 @@ class PriceSheetController extends BaseController $this->service = PriceSheetService::getInstance(); $this->insertField = ['catalogue_id']; $this->updateField = ['id']; - $this->notRequest = ['specification', 'dimension', 'routine', 'rows', 'status']; + $this->notRequest = ['specification', 'dimension', 'routine', 'stock', 'rows', 'status']; } /** diff --git a/app/Http/Controllers/Api/WxAppController.php b/app/Http/Controllers/Api/WxAppController.php index 4c295368..7f8ee33a 100644 --- a/app/Http/Controllers/Api/WxAppController.php +++ b/app/Http/Controllers/Api/WxAppController.php @@ -19,7 +19,8 @@ class WxAppController extends BaseController $this->updateField = ['id']; $this->notRequest = [ 'app_secret', 'mch_id', 'mch_key', 'mch_serial_no', 'mch_private_key', - 'platform_public_key', 'notify_url', 'template_code', 'package_enabled', 'remark', 'name', 'app_id', + 'platform_public_key', 'notify_url', 'template_code', 'package_enabled', + 'subscribe_pay_tpl', 'subscribe_ship_tpl', 'remark', 'name', 'app_id', ]; } diff --git a/app/Http/Controllers/Wx/AddressController.php b/app/Http/Controllers/Wx/AddressController.php new file mode 100644 index 00000000..220ca39f --- /dev/null +++ b/app/Http/Controllers/Wx/AddressController.php @@ -0,0 +1,39 @@ +service = WxAddressService::getInstance(); + } + + /** + * 默认地址 + * @Method GET + */ + public function defaultOne(): JsonResponse + { + return jok($this->service->defaultOne()); + } + + /** + * 设为默认 + * @Method POST + */ + public function setDefault(): JsonResponse + { + return jok($this->service->setDefault((int) request()->post('id', 0)), '已设为默认'); + } +} diff --git a/app/Http/Controllers/Wx/AfterSaleController.php b/app/Http/Controllers/Wx/AfterSaleController.php new file mode 100644 index 00000000..55a8a514 --- /dev/null +++ b/app/Http/Controllers/Wx/AfterSaleController.php @@ -0,0 +1,30 @@ +service = WxAfterSaleService::getInstance(); + } + + /** + * 申请售后 + * @Method POST + */ + public function create(): JsonResponse + { + return jok($this->service->create(request()->post()), '已提交'); + } +} diff --git a/app/Http/Controllers/Wx/ColorcardController.php b/app/Http/Controllers/Wx/ColorcardController.php new file mode 100644 index 00000000..a64c7ab1 --- /dev/null +++ b/app/Http/Controllers/Wx/ColorcardController.php @@ -0,0 +1,39 @@ +service = WxColorcardService::getInstance(); + } + + /** + * 色卡分类 + * @Method GET + */ + public function classList(): JsonResponse + { + return jok($this->service->classList()); + } + + /** + * 色卡公司 + * @Method GET + */ + public function companyList(): JsonResponse + { + return jok($this->service->companyList()); + } +} diff --git a/app/Http/Controllers/Wx/FactoryController.php b/app/Http/Controllers/Wx/FactoryController.php new file mode 100644 index 00000000..a71980ec --- /dev/null +++ b/app/Http/Controllers/Wx/FactoryController.php @@ -0,0 +1,30 @@ +service = WxFactoryService::getInstance(); + } + + /** + * 工厂分类 + * @Method GET + */ + public function classList(): JsonResponse + { + return jok($this->service->classList()); + } +} diff --git a/app/Http/Controllers/Wx/FavoriteController.php b/app/Http/Controllers/Wx/FavoriteController.php new file mode 100644 index 00000000..4fad65b9 --- /dev/null +++ b/app/Http/Controllers/Wx/FavoriteController.php @@ -0,0 +1,30 @@ +service = WxFavoriteService::getInstance(); + } + + /** + * 切换收藏 + * @Method POST + */ + public function toggle(): JsonResponse + { + return jok($this->service->toggle((int) request()->post('catalogue_id', 0))); + } +} diff --git a/app/Http/Controllers/Wx/FeedbackController.php b/app/Http/Controllers/Wx/FeedbackController.php new file mode 100644 index 00000000..1af7f965 --- /dev/null +++ b/app/Http/Controllers/Wx/FeedbackController.php @@ -0,0 +1,30 @@ +service = WxFeedbackService::getInstance(); + } + + /** + * 提交反馈 + * @Method POST + */ + public function create(): JsonResponse + { + return jok($this->service->create(request()->post()), '已提交'); + } +} diff --git a/app/Http/Controllers/Wx/ListController.php b/app/Http/Controllers/Wx/ListController.php index ee02fd4f..9092eb36 100644 --- a/app/Http/Controllers/Wx/ListController.php +++ b/app/Http/Controllers/Wx/ListController.php @@ -93,4 +93,13 @@ class ListController extends BaseController { return jok($this->service->updateItem(request()->post()), '保存成功'); } + + /** + * 导出清单报价 + * @Method GET + */ + public function exportQuote(): JsonResponse + { + return jok($this->service->exportQuote((int) request()->get('id', 0))); + } } diff --git a/app/Http/Controllers/Wx/UserController.php b/app/Http/Controllers/Wx/UserController.php index 478f0360..c95a2de4 100644 --- a/app/Http/Controllers/Wx/UserController.php +++ b/app/Http/Controllers/Wx/UserController.php @@ -48,4 +48,13 @@ class UserController extends BaseController '保存成功' ); } + + /** + * 已绑定企业详情 + * @Method GET + */ + public function enterprise(): JsonResponse + { + return jok($this->service->enterprise()); + } } diff --git a/app/Models/business/AfterSaleModel.php b/app/Models/business/AfterSaleModel.php new file mode 100644 index 00000000..35342e2e --- /dev/null +++ b/app/Models/business/AfterSaleModel.php @@ -0,0 +1,32 @@ +belongsTo(OrderModel::class, 'order_id', 'id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(WxUserModel::class, 'user_id', 'id'); + } +} diff --git a/app/Models/business/FavoriteModel.php b/app/Models/business/FavoriteModel.php new file mode 100644 index 00000000..373708a4 --- /dev/null +++ b/app/Models/business/FavoriteModel.php @@ -0,0 +1,21 @@ +belongsTo(CatalogueModel::class, 'catalogue_id', 'id'); + } +} diff --git a/app/Models/business/FeedbackModel.php b/app/Models/business/FeedbackModel.php new file mode 100644 index 00000000..75cc7cb3 --- /dev/null +++ b/app/Models/business/FeedbackModel.php @@ -0,0 +1,25 @@ +belongsTo(WxUserModel::class, 'user_id', 'id'); + } +} diff --git a/app/Models/business/WxAddressModel.php b/app/Models/business/WxAddressModel.php new file mode 100644 index 00000000..dae111fd --- /dev/null +++ b/app/Models/business/WxAddressModel.php @@ -0,0 +1,15 @@ +model = WxAppModel::class; $this->selectField = [ 'id', 'code', 'name', 'app_id', 'mch_id', 'mch_serial_no', 'notify_url', - 'template_code', 'package_enabled', 'status', 'remark', 'created_at', 'updated_at', + 'template_code', 'package_enabled', 'subscribe_pay_tpl', 'subscribe_ship_tpl', + 'status', 'remark', 'created_at', 'updated_at', ]; $this->queryField = ['code' => '=', 'name' => 'like', 'status' => '=']; $this->orderBy = ['name' => 'id', 'sort' => 'asc']; diff --git a/app/Service/business/AfterSaleService.php b/app/Service/business/AfterSaleService.php new file mode 100644 index 00000000..5382ac43 --- /dev/null +++ b/app/Service/business/AfterSaleService.php @@ -0,0 +1,61 @@ +model = AfterSaleModel::class; + $this->selectField = [ + 'id', 'order_id', 'user_id', 'reason', 'amount', 'images', + 'status', 'admin_remark', 'created_at', 'updated_at', + ]; + $this->queryField = ['order_id' => '=', 'user_id' => '=', 'status' => '=']; + $this->with = ['order', 'user']; + } + + public function list(): array + { + $result = $this->getPageList(); + $price = PriceService::getInstance(); + foreach ($result['items'] as &$item) { + $item['user_name'] = $item['user']['nick_name'] ?? ''; + $item['order_no'] = $item['order']['order_no'] ?? ''; + $item['amount_text'] = $price->centsToYuan((int) ($item['amount'] ?? 0)); + unset($item['user'], $item['order']); + } + unset($item); + return $result; + } + + /** + * 审核:pass=true 通过 + */ + public function audit(int $id, bool $pass, string $remark = ''): mixed + { + $row = AfterSaleModel::where('id', $id)->where('deleted_at', 0)->first(); + if (empty($row)) { + $this->utils->errorThrow('售后单不存在'); + } + if ((int) $row['status'] !== AfterSaleModel::STATUS_PENDING) { + $this->utils->errorThrow('已处理过'); + } + return $this->save($id, [ + 'status' => $pass ? AfterSaleModel::STATUS_APPROVED : AfterSaleModel::STATUS_REJECTED, + 'admin_remark' => mb_substr(trim($remark), 0, 255), + ]); + } + + public function delete($ids): mixed + { + return $this->del(is_array($ids) ? $ids : [$ids]); + } +} diff --git a/app/Service/business/CatalogueService.php b/app/Service/business/CatalogueService.php index e9036ff6..15cf7b68 100644 --- a/app/Service/business/CatalogueService.php +++ b/app/Service/business/CatalogueService.php @@ -9,6 +9,7 @@ use App\Models\business\ImageModel; use App\Models\business\PriceSheetModel; use App\Service\common\MediaUrlService; use Exception; +use Illuminate\Support\Facades\DB; use Psr\Container\ContainerExceptionInterface; use Psr\Container\NotFoundExceptionInterface; @@ -23,7 +24,7 @@ class CatalogueService extends BaseService { parent::__construct(); $this->model = CatalogueModel::class; - $this->selectField = ['id', 'title', 'category_id', 'cover', 'pdf', 'price', 'alias', 'identifier', 'status', 'created_at', 'updated_at']; + $this->selectField = ['id', 'title', 'category_id', 'cover', 'pdf', 'video', 'price', 'alias', 'identifier', 'status', 'created_at', 'updated_at']; $this->queryField = ['title' => 'like', 'alias' => 'like', 'identifier' => 'like', 'status' => '=']; $this->media = MediaUrlService::getInstance(); } @@ -55,6 +56,7 @@ class CatalogueService extends BaseService unset($item['category']); $item['cover'] = $this->media->toPublic($item['cover'] ?? ''); $item['pdf'] = $this->media->toPublic($item['pdf'] ?? ''); + $item['video'] = $this->media->toPublic($item['video'] ?? ''); $item['show_field'] = $this->pickUsedMaterialFields($item['price_sheet'] ?? []); } unset($item); @@ -76,6 +78,7 @@ class CatalogueService extends BaseService $info = $this->getDetail($id); $info->cover = $this->media->toPublic($info->cover); $info->pdf = $this->media->toPublic($info->pdf); + $info->video = $this->media->toPublic($info->video); $priceSheet = PriceSheetModel::where('catalogue_id', $id) ->where('deleted_at', 0) @@ -134,6 +137,141 @@ class CatalogueService extends BaseService return $this->save($id, ['status' => (int) $status]); } + /** + * 导出商品 + 关联报价单:两个工作表,xlsx 由前端 ExcelJS 美化 + */ + public function exportExcel(): array + { + $rows = CatalogueModel::where('deleted_at', 0)->orderByDesc('id')->get([ + 'id', 'title', 'identifier', 'alias', 'category_id', 'price', 'status', + ])->toArray(); + $byId = []; + foreach ($rows as &$row) { + $row['status'] = (int) ($row['status'] ?? 0) === 0 ? '上架' : '下架'; + $byId[(int) $row['id']] = $row; + } + unset($row); + + $ids = array_keys($byId); + $priceRows = []; + if (!empty($ids)) { + $sheets = PriceSheetModel::where('deleted_at', 0) + ->whereIn('catalogue_id', $ids) + ->orderBy('catalogue_id') + ->orderBy('id') + ->get(['id', 'catalogue_id', 'specification', 'dimension', 'routine', 'stock', 'status']) + ->toArray(); + foreach ($sheets as $sheet) { + $cat = $byId[(int) $sheet['catalogue_id']] ?? []; + $priceRows[] = [ + 'id' => $sheet['id'], + 'catalogue_id' => $sheet['catalogue_id'], + 'identifier' => $cat['identifier'] ?? '', + 'title' => $cat['title'] ?? '', + 'specification' => $sheet['specification'] ?? '', + 'dimension' => $sheet['dimension'] ?? '', + 'routine' => $sheet['routine'] ?? '', + 'stock' => (int) ($sheet['stock'] ?? -1), + 'status' => (int) ($sheet['status'] ?? 0) === 0 ? '启用' : '禁用', + ]; + } + } + + return [ + 'filename' => 'catalogue.xlsx', + 'title' => '商品图册', + 'sheets' => [ + [ + 'title' => '商品图册', + 'sheet' => '商品', + 'columns' => [ + ['key' => 'id', 'header' => 'ID', 'width' => 10, 'align' => 'center'], + ['key' => 'title', 'header' => '商品名称', 'width' => 28], + ['key' => 'identifier', 'header' => '商品编号', 'width' => 16], + ['key' => 'alias', 'header' => '别名', 'width' => 24], + ['key' => 'category_id', 'header' => '分类ID', 'width' => 12, 'align' => 'center'], + ['key' => 'price', 'header' => '参考价', 'width' => 14, 'align' => 'right'], + ['key' => 'status', 'header' => '状态', 'width' => 10, 'align' => 'center'], + ], + 'rows' => $rows, + ], + [ + 'title' => '关联报价单', + 'sheet' => '报价单', + 'subtitle' => '按商品编号回写;库存 -1 表示不限', + 'columns' => [ + ['key' => 'id', 'header' => 'ID', 'width' => 10, 'align' => 'center'], + ['key' => 'catalogue_id', 'header' => '商品ID', 'width' => 12, 'align' => 'center'], + ['key' => 'identifier', 'header' => '商品编号', 'width' => 16], + ['key' => 'title', 'header' => '商品名称', 'width' => 24], + ['key' => 'specification', 'header' => '规格', 'width' => 20], + ['key' => 'dimension', 'header' => '尺寸', 'width' => 16], + ['key' => 'routine', 'header' => '常规价', 'width' => 14, 'align' => 'right'], + ['key' => 'stock', 'header' => '库存', 'width' => 10, 'align' => 'center'], + ['key' => 'status', 'header' => '状态', 'width' => 10, 'align' => 'center'], + ], + 'rows' => $priceRows, + ], + ], + ]; + } + + /** + * 导入商品 + 报价单:先落商品,再按编号挂规格 + */ + public function importRows(array $rows, array $priceSheets = []): array + { + $created = 0; + $updated = 0; + $priceCreated = 0; + $priceUpdated = 0; + $now = time(); + DB::connection('business')->beginTransaction(); + try { + foreach ($rows as $row) { + if (!is_array($row)) { + continue; + } + $identifier = trim((string) ($row['identifier'] ?? $row['商品编号'] ?? '')); + $title = trim((string) ($row['title'] ?? $row['商品名称'] ?? '')); + if ($title === '') { + continue; + } + $payload = [ + 'title' => $title, + 'identifier' => $identifier, + 'alias' => (string) ($row['alias'] ?? $row['别名'] ?? ''), + 'category_id' => (int) ($row['category_id'] ?? $row['分类ID'] ?? 0), + 'price' => (string) ($row['price'] ?? $row['参考价'] ?? ''), + 'status' => $this->parseStatus($row['status'] ?? $row['状态'] ?? 0), + 'updated_at' => $now, + ]; + $exists = $identifier !== '' + ? CatalogueModel::where('identifier', $identifier)->where('deleted_at', 0)->first() + : null; + if (!empty($exists)) { + CatalogueModel::where('id', $exists['id'])->update($payload); + $updated++; + } else { + $payload['created_at'] = $now; + CatalogueModel::insert($payload); + $created++; + } + } + [$priceCreated, $priceUpdated] = $this->importPriceSheets($priceSheets, $now); + DB::connection('business')->commit(); + } catch (Exception $e) { + DB::connection('business')->rollBack(); + $this->utils->errorThrow($e->getMessage()); + } + return [ + 'created' => $created, + 'updated' => $updated, + 'price_created' => $priceCreated, + 'price_updated' => $priceUpdated, + ]; + } + /** * 报价单里真正有值的材质列 */ @@ -164,7 +302,7 @@ class CatalogueService extends BaseService private function normalize(array $params): array { - foreach (['cover', 'pdf'] as $field) { + foreach (['cover', 'pdf', 'video'] as $field) { if (array_key_exists($field, $params)) { $params[$field] = $this->media->firstOf($params[$field]); } @@ -190,4 +328,100 @@ class CatalogueService extends BaseService $this->utils->errorThrow('商品编号已存在:' . $identifier); } } + + /** + * 导入报价单:优先商品编号,其次商品ID;同商品同规格同尺寸则更新 + */ + private function importPriceSheets(array $rows, int $now): array + { + $created = 0; + $updated = 0; + foreach ($rows as $row) { + if (!is_array($row)) { + continue; + } + $catalogueId = $this->resolveCatalogueId($row); + $specification = trim((string) ($row['specification'] ?? $row['规格'] ?? '')); + if ($catalogueId <= 0 || $specification === '') { + continue; + } + $dimension = (string) ($row['dimension'] ?? $row['尺寸'] ?? ''); + $payload = [ + 'catalogue_id' => $catalogueId, + 'specification' => $specification, + 'dimension' => $dimension, + 'routine' => (string) ($row['routine'] ?? $row['常规价'] ?? ''), + 'stock' => $this->parseStock($row['stock'] ?? $row['库存'] ?? -1), + 'status' => $this->parseStatus($row['status'] ?? $row['状态'] ?? 0), + 'updated_at' => $now, + ]; + $id = (int) ($row['id'] ?? $row['ID'] ?? 0); + $exists = $id > 0 + ? PriceSheetModel::where('id', $id)->where('catalogue_id', $catalogueId)->where('deleted_at', 0)->first() + : null; + if (empty($exists)) { + $exists = PriceSheetModel::where('catalogue_id', $catalogueId) + ->where('specification', $specification) + ->where('dimension', $dimension) + ->where('deleted_at', 0) + ->first(); + } + if (!empty($exists)) { + PriceSheetModel::where('id', $exists['id'])->update($payload); + $updated++; + } else { + $payload['created_at'] = $now; + PriceSheetModel::insert($payload); + $created++; + } + } + return [$created, $updated]; + } + + /** + * 报价单行挂到哪件商品:编号优先,兼容导出里的商品ID + */ + private function resolveCatalogueId(array $row): int + { + $identifier = trim((string) ($row['identifier'] ?? $row['商品编号'] ?? '')); + if ($identifier !== '') { + $cat = CatalogueModel::where('identifier', $identifier)->where('deleted_at', 0)->first(); + if (!empty($cat)) { + return (int) $cat['id']; + } + } + $id = (int) ($row['catalogue_id'] ?? $row['商品ID'] ?? 0); + if ($id <= 0) { + return 0; + } + $exists = CatalogueModel::where('id', $id)->where('deleted_at', 0)->exists(); + return $exists ? $id : 0; + } + + /** + * Excel 状态列可能是「上架/下架」或 0/1,统一成库里的数字 + */ + private function parseStatus(mixed $value): int + { + $raw = trim((string) $value); + if (in_array($raw, ['上架', '启用', '正常', '0', ''], true)) { + return 0; + } + if (in_array($raw, ['下架', '禁用', '1'], true)) { + return 1; + } + return ((int) $raw) === 1 ? 1 : 0; + } + + /** + * 库存:空 / 不限 → -1 + */ + private function parseStock(mixed $value): int + { + $raw = trim((string) $value); + if ($raw === '' || in_array($raw, ['不限', '无限', '-1'], true)) { + return -1; + } + return (int) $raw; + } } diff --git a/app/Service/business/FactoryInfoService.php b/app/Service/business/FactoryInfoService.php index 7afb8f17..6dd8bc4b 100644 --- a/app/Service/business/FactoryInfoService.php +++ b/app/Service/business/FactoryInfoService.php @@ -100,4 +100,84 @@ class FactoryInfoService extends BaseService { return $this->save($id, ['status' => (int) $status]); } + + /** + * 导出工厂表:只回结构化数据,xlsx 由前端 ExcelJS 美化 + */ + public function exportExcel(): array + { + $rows = FactoryInfoModel::where('deleted_at', 0)->orderByDesc('id')->get([ + 'id', 'name', 'phone', 'classification', 'address', 'status', + ])->toArray(); + foreach ($rows as &$row) { + $row['status'] = (int) ($row['status'] ?? 0) === 0 ? '启用' : '禁用'; + } + unset($row); + return [ + 'filename' => 'factory.xlsx', + 'title' => '工厂信息', + 'sheet' => '工厂', + 'columns' => [ + ['key' => 'id', 'header' => 'ID', 'width' => 10, 'align' => 'center'], + ['key' => 'name', 'header' => '工厂名称', 'width' => 22], + ['key' => 'phone', 'header' => '电话', 'width' => 16], + ['key' => 'classification', 'header' => '分类ID', 'width' => 12, 'align' => 'center'], + ['key' => 'address', 'header' => '地址', 'width' => 32], + ['key' => 'status', 'header' => '状态', 'width' => 10, 'align' => 'center'], + ], + 'rows' => $rows, + ]; + } + + /** + * 导入工厂:前端 ExcelJS 拆好的行,按 name 更新 + */ + public function importRows(array $rows): array + { + $created = 0; + $updated = 0; + $now = time(); + foreach ($rows as $row) { + if (!is_array($row)) { + continue; + } + $name = trim((string) ($row['name'] ?? $row['工厂名称'] ?? '')); + if ($name === '') { + continue; + } + $payload = [ + 'name' => $name, + 'phone' => (string) ($row['phone'] ?? $row['电话'] ?? ''), + 'classification' => (int) ($row['classification'] ?? $row['分类ID'] ?? 0), + 'address' => (string) ($row['address'] ?? $row['地址'] ?? ''), + 'status' => $this->parseStatus($row['status'] ?? $row['状态'] ?? 0), + 'updated_at' => $now, + ]; + $exists = FactoryInfoModel::where('name', $name)->where('deleted_at', 0)->first(); + if (!empty($exists)) { + FactoryInfoModel::where('id', $exists['id'])->update($payload); + $updated++; + } else { + $payload['created_at'] = $now; + FactoryInfoModel::insert($payload); + $created++; + } + } + return ['created' => $created, 'updated' => $updated]; + } + + /** + * Excel 状态列可能是「启用/禁用」或 0/1,统一成库里的数字 + */ + private function parseStatus(mixed $value): int + { + $raw = trim((string) $value); + if (in_array($raw, ['上架', '启用', '正常', '0', ''], true)) { + return 0; + } + if (in_array($raw, ['下架', '禁用', '1'], true)) { + return 1; + } + return ((int) $raw) === 1 ? 1 : 0; + } } diff --git a/app/Service/business/FeedbackService.php b/app/Service/business/FeedbackService.php new file mode 100644 index 00000000..bd3c7b3a --- /dev/null +++ b/app/Service/business/FeedbackService.php @@ -0,0 +1,56 @@ +model = FeedbackModel::class; + $this->selectField = ['id', 'user_id', 'content', 'images', 'reply', 'status', 'created_at', 'updated_at']; + $this->queryField = ['user_id' => '=', 'status' => '=', 'content' => 'like']; + $this->with = ['user']; + } + + public function list(): array + { + $result = $this->getPageList(); + foreach ($result['items'] as &$item) { + $item['user_name'] = $item['user']['nick_name'] ?? ''; + $item['user_phone'] = $item['user']['phone'] ?? ''; + unset($item['user']); + } + unset($item); + return $result; + } + + /** + * 回复反馈 + */ + public function reply(int $id, string $reply): mixed + { + if ($id <= 0) { + $this->utils->errorThrow('参数错误'); + } + $reply = trim($reply); + if ($reply === '') { + $this->utils->errorThrow('请填写回复'); + } + return $this->save($id, [ + 'reply' => mb_substr($reply, 0, 500), + 'status' => FeedbackModel::STATUS_REPLIED, + ]); + } + + public function delete($ids): mixed + { + return $this->del(is_array($ids) ? $ids : [$ids]); + } +} diff --git a/app/Service/business/ListService.php b/app/Service/business/ListService.php index d9162ad4..603a19c4 100644 --- a/app/Service/business/ListService.php +++ b/app/Service/business/ListService.php @@ -222,4 +222,16 @@ class ListService extends BaseService 'updated_at' => time(), ]); } + + /** + * 导出清单报价:后台画 xlsx,小程序用 text 分享 + */ + public function exportQuote(int $id): array + { + $info = $this->detail($id); + if (!is_array($info)) { + $this->utils->errorThrow('清单不存在'); + } + return \App\Service\common\ExcelCsvService::getInstance()->quoteFromList($info); + } } diff --git a/app/Service/business/OrderCoreService.php b/app/Service/business/OrderCoreService.php index 6cfe2092..5da681fe 100644 --- a/app/Service/business/OrderCoreService.php +++ b/app/Service/business/OrderCoreService.php @@ -91,6 +91,7 @@ class OrderCoreService continue; } $quantity = max(1, (int) $item['quantity']); + StockService::getInstance()->assertAvailable((int) $item['price_sheet_id'], $quantity); $unitPrice = (int) $item['unit_price']; if ($unitPrice <= 0) { $unitPrice = $price->resolveUnitPrice($sheet['routine'] ?? '', (string) $item['material_key'], $multiplier); @@ -152,6 +153,9 @@ class OrderCoreService } unset($row); OrderItemModel::insert($rows); + foreach ($rows as $row) { + StockService::getInstance()->deduct((int) $row['price_sheet_id'], (int) $row['quantity']); + } ListModel::where('id', $list['id'])->update(['status' => 1, 'updated_at' => $now]); }); @@ -270,6 +274,9 @@ class OrderCoreService } $this->applyPaid($orderId, (int) $payment['amount'], $now); }); + if ($status === OrderPaymentModel::STATUS_CONFIRMED) { + $this->notifySubscribe($orderId, 'pay'); + } return $this->detail($orderId); } @@ -308,11 +315,14 @@ class OrderCoreService public function confirmWechatPay(string $outTradeNo, string $transactionId, int $amount): bool { $done = false; - DB::connection('business')->transaction(function () use ($outTradeNo, $transactionId, $amount, &$done) { + $fresh = false; + $orderId = 0; + DB::connection('business')->transaction(function () use ($outTradeNo, $transactionId, $amount, &$done, &$fresh, &$orderId) { $payment = OrderPaymentModel::where('out_trade_no', $outTradeNo)->lockForUpdate()->first(); if (empty($payment)) { return; } + $orderId = (int) $payment['order_id']; if ((int) $payment['status'] === OrderPaymentModel::STATUS_CONFIRMED) { $done = true; return; @@ -325,9 +335,13 @@ class OrderCoreService 'audited_at' => $now, 'updated_at' => $now, ]); - $this->applyPaid((int) $payment['order_id'], $amount > 0 ? $amount : (int) $payment['amount'], $now); + $this->applyPaid($orderId, $amount > 0 ? $amount : (int) $payment['amount'], $now); $done = true; + $fresh = true; }); + if ($fresh && $orderId > 0) { + $this->notifySubscribe($orderId, 'pay'); + } return $done; } @@ -367,6 +381,7 @@ class OrderCoreService 'updated_at' => $now, ]); }); + $this->notifySubscribe($orderId, 'ship'); return $this->detail($orderId); } @@ -405,4 +420,47 @@ class OrderCoreService } return $order->toArray(); } + + /** + * 付款/发货后尽力发订阅消息,失败不影响主流程 + */ + private function notifySubscribe(int $orderId, string $scene): void + { + if ($orderId <= 0) { + return; + } + try { + $order = OrderModel::where('id', $orderId)->first(); + if (empty($order)) { + return; + } + $user = WxUserModel::where('id', (int) $order['user_id'])->first(['open_id']); + $openId = (string) ($user['open_id'] ?? ''); + if ($openId === '') { + return; + } + $app = \App\Service\wx\WxAppService::getInstance()->current(); + $tplKey = $scene === 'ship' ? 'subscribe_ship_tpl' : 'subscribe_pay_tpl'; + $templateId = trim((string) ($app[$tplKey] ?? '')); + if ($templateId === '') { + return; + } + $price = PriceService::getInstance(); + $page = 'pages/order/detail?id=' . $orderId; + $data = $scene === 'ship' + ? [ + 'character_string1' => ['value' => (string) $order['order_no']], + 'thing2' => ['value' => '订单已发货'], + 'time3' => ['value' => date('Y-m-d H:i')], + ] + : [ + 'character_string1' => ['value' => (string) $order['order_no']], + 'amount2' => ['value' => $price->centsToYuan((int) $order['total_amount'])], + 'thing3' => ['value' => '付款成功'], + ]; + \App\Service\wx\WxMiniService::getInstance()->sendSubscribe($openId, $templateId, $page, $data); + } catch (\Throwable) { + // 订阅消息是附加能力,模板字段对不上或用户未授权都不能挡住收款/发货 + } + } } diff --git a/app/Service/business/OrderService.php b/app/Service/business/OrderService.php index b52b647e..791f142e 100644 --- a/app/Service/business/OrderService.php +++ b/app/Service/business/OrderService.php @@ -3,6 +3,7 @@ namespace App\Service\business; use App\BaseApp\BaseService; +use App\Models\business\ListModel; use App\Models\business\OrderModel; use App\Models\business\OrderPaymentModel; use App\Models\business\WxUserModel; @@ -188,6 +189,15 @@ class OrderService extends BaseService $stat['auditing'] = OrderPaymentModel::where('deleted_at', 0) ->where('status', OrderPaymentModel::STATUS_AUDITING) ->count(); + $stat['list_count'] = ListModel::where('deleted_at', 0)->count(); + $stat['wx_user_count'] = WxUserModel::where('deleted_at', 0)->count(); + $statusMap = []; + foreach ($stat['status'] as $row) { + $statusMap[(int) $row['status']] = (int) $row['count']; + } + $stat['unpaid'] = $statusMap[OrderModel::STATUS_UNPAID] ?? 0; + $stat['paid'] = $statusMap[OrderModel::STATUS_PAID] ?? 0; + $stat['shipped'] = $statusMap[OrderModel::STATUS_SHIPPED] ?? 0; return $stat; } } diff --git a/app/Service/business/PriceSheetService.php b/app/Service/business/PriceSheetService.php index ee1cdae1..97e28839 100644 --- a/app/Service/business/PriceSheetService.php +++ b/app/Service/business/PriceSheetService.php @@ -26,7 +26,7 @@ class PriceSheetService extends BaseService $this->selectField = array_merge( ['id', 'catalogue_id', 'specification', 'dimension'], PriceSheetModel::MATERIAL_FIELDS, - ['status', 'created_at', 'updated_at'] + ['stock', 'status', 'created_at', 'updated_at'] ); $this->queryField = ['catalogue_id' => '=', 'specification' => 'like', 'status' => '=']; $this->orderBy = ['name' => 'id', 'sort' => 'asc']; @@ -195,6 +195,9 @@ class PriceSheetService extends BaseService $payload[$field] = is_scalar($row[$field]) ? (string) $row[$field] : ''; } } + if (array_key_exists('stock', $row)) { + $payload['stock'] = (int) $row['stock']; + } return $payload; } diff --git a/app/Service/business/StockService.php b/app/Service/business/StockService.php new file mode 100644 index 00000000..e7459e72 --- /dev/null +++ b/app/Service/business/StockService.php @@ -0,0 +1,67 @@ +where('deleted_at', 0)->first(); + if (empty($sheet)) { + UtilsService::getInstance()->errorThrow('规格不存在'); + } + $stock = (int) ($sheet['stock'] ?? -1); + if ($stock < 0) { + return; + } + if ($stock < $quantity) { + UtilsService::getInstance()->errorThrow('库存不足(剩余 ' . $stock . ')'); + } + } + + /** + * 下单成功后扣库存;不限库存不写库。 + */ + public function deduct(int $sheetId, int $quantity): void + { + if ($sheetId <= 0 || $quantity <= 0) { + return; + } + $sheet = PriceSheetModel::where('id', $sheetId)->where('deleted_at', 0)->first(); + if (empty($sheet)) { + return; + } + $stock = (int) ($sheet['stock'] ?? -1); + if ($stock < 0) { + return; + } + $next = max(0, $stock - $quantity); + PriceSheetModel::where('id', $sheetId)->update([ + 'stock' => $next, + 'updated_at' => time(), + ]); + } +} diff --git a/app/Service/business/WxTemplateSchemaService.php b/app/Service/business/WxTemplateSchemaService.php index 44dcdb11..42411a91 100644 --- a/app/Service/business/WxTemplateSchemaService.php +++ b/app/Service/business/WxTemplateSchemaService.php @@ -56,6 +56,9 @@ class WxTemplateSchemaService 'header' => ['gradient', 'image', 'plain', 'split', 'editorial'], 'menu' => ['grid', 'list', 'card', 'tile', 'compact'], ], + 'package' => [ + 'list' => ['card', 'featured', 'magazine', 'list'], + ], 'effect' => [ 'transition' => ['fade', 'slide', 'zoom', 'none'], 'skeleton' => ['shimmer', 'pulse', 'none'], @@ -70,7 +73,7 @@ class WxTemplateSchemaService */ public const PAGE_SECTION_TYPES = [ 'home' => ['search', 'hero', 'category', 'package', 'product'], - 'package' => ['list'], + 'package' => ['search', 'list'], 'catalog' => ['filter', 'list'], 'search' => ['search', 'list'], 'product' => ['gallery', 'info', 'spec', 'price', 'action'], @@ -90,7 +93,7 @@ class WxTemplateSchemaService 'filter' => ['chip', 'bar', 'sidebar', 'hidden'], 'list' => [ 'card', 'table', 'timeline', 'waterfall', 'grid', 'list', - 'masonry', 'featured', 'shelf', 'compact', 'airy', 'mosaic', 'duo', + 'masonry', 'featured', 'magazine', 'shelf', 'compact', 'airy', 'mosaic', 'duo', 'ticket', 'stacked', ], 'gallery' => ['swiper', 'stack', 'fullbleed', 'peek', 'fade', 'coverflow', 'mosaic', 'filmstrip'], @@ -111,6 +114,7 @@ class WxTemplateSchemaService 'package' => ['card', 'scroll', 'featured', 'magazine'], ], 'package' => [ + 'search' => ['bar', 'overlay', 'pill', 'float'], 'list' => ['card', 'featured', 'magazine', 'list'], ], 'catalog' => [ @@ -198,6 +202,7 @@ class WxTemplateSchemaService 'mosaic' => '马赛克', 'duo' => '对开', 'card' => '卡片', + 'magazine' => '刊名条', 'table' => '表格', 'timeline' => '时间线', 'ticket' => '票根', diff --git a/app/Service/common/ExcelCsvService.php b/app/Service/common/ExcelCsvService.php new file mode 100644 index 00000000..2342fab4 --- /dev/null +++ b/app/Service/common/ExcelCsvService.php @@ -0,0 +1,142 @@ +errorThrow('请上传表格文件'); + } + $path = is_string($file) ? $file : $file->getRealPath(); + $raw = file_get_contents($path); + if ($raw === false) { + UtilsService::getInstance()->errorThrow('文件读取失败'); + } + if (str_starts_with($raw, "\xEF\xBB\xBF")) { + $raw = substr($raw, 3); + } + $fp = fopen('php://temp', 'r+'); + fwrite($fp, $raw); + rewind($fp); + $header = fgetcsv($fp); + if (empty($header)) { + fclose($fp); + UtilsService::getInstance()->errorThrow('表格没有表头'); + } + $header = array_map(static fn ($v) => trim((string) $v), $header); + $rows = []; + while (($cols = fgetcsv($fp)) !== false) { + if ($this->rowEmpty($cols)) { + continue; + } + $item = []; + foreach ($header as $i => $key) { + $item[$key] = isset($cols[$i]) ? trim((string) $cols[$i]) : ''; + } + $rows[] = $item; + } + fclose($fp); + return $rows; + } + + /** + * 清单报价:后台用 ExcelJS 画 xlsx,小程序继续用 text 分享 + */ + public function quoteFromList(array $info): array + { + $rows = []; + $lines = []; + $name = (string) ($info['name'] ?? $info['list_no'] ?? '报价'); + $lines[] = $name; + foreach ($info['items'] ?? [] as $item) { + $title = (string) ($item['title'] ?? $item['catalogue']['title'] ?? $item['product']['title'] ?? ''); + $spec = (string) ($item['specification'] ?? $item['price_sheet']['specification'] ?? ''); + $qty = (int) ($item['quantity'] ?? 1); + $unit = (string) ($item['unit_price_text'] ?? ''); + $total = (string) ($item['total_price_text'] ?? ''); + $rows[] = [ + 'title' => $title, + 'specification' => $spec, + 'quantity' => $qty, + 'unit_price' => $unit, + 'total_price' => $total, + ]; + $lines[] = trim($title . ' ' . $spec . ' x' . $qty . ($unit !== '' ? ' ¥' . $unit : '')); + } + $payable = (string) ($info['payable_amount_text'] ?? $info['total_amount_text'] ?? ''); + if ($payable !== '') { + $lines[] = '合计 ¥' . $payable; + } + $no = (string) ($info['list_no'] ?? 'quote'); + return [ + 'filename' => 'quote-' . $no . '.xlsx', + 'title' => $name, + 'sheet' => '报价', + 'subtitle' => '清单号 ' . $no, + 'columns' => [ + ['key' => 'title', 'header' => '商品', 'width' => 28], + ['key' => 'specification', 'header' => '规格', 'width' => 22], + ['key' => 'quantity', 'header' => '数量', 'width' => 10, 'align' => 'center'], + ['key' => 'unit_price', 'header' => '单价', 'width' => 14, 'align' => 'right'], + ['key' => 'total_price', 'header' => '小计', 'width' => 14, 'align' => 'right'], + ], + 'rows' => $rows, + 'summary' => [ + 'title' => '合计', + 'total_price' => $payable === '' ? '' : ('¥' . $payable), + ], + 'text' => implode("\n", $lines), + ]; + } + + private function rowEmpty(array $cols): bool + { + foreach ($cols as $col) { + if (trim((string) $col) !== '') { + return false; + } + } + return true; + } +} diff --git a/app/Service/wx/WxAddressService.php b/app/Service/wx/WxAddressService.php new file mode 100644 index 00000000..e1fe6f05 --- /dev/null +++ b/app/Service/wx/WxAddressService.php @@ -0,0 +1,117 @@ +userId) + ->where('deleted_at', 0) + ->orderByDesc('is_default') + ->orderByDesc('id') + ->get() + ->toArray(); + } + + /** + * 默认地址,下单表单回填 + */ + public function defaultOne(): array + { + $row = WxAddressModel::where('user_id', $this->userId) + ->where('deleted_at', 0) + ->orderByDesc('is_default') + ->orderByDesc('id') + ->first(); + return $row ? $row->toArray() : []; + } + + public function create(array $params): array + { + $payload = $this->pick($params); + $now = time(); + if ((int) ($payload['is_default'] ?? 0) === 1) { + $this->clearDefault(); + } + $id = (int) WxAddressModel::insertGetId(array_merge($payload, [ + 'user_id' => $this->userId, + 'created_at' => $now, + 'updated_at' => $now, + ])); + return WxAddressModel::where('id', $id)->first()->toArray(); + } + + public function update($id, $params): mixed + { + $row = $this->owned((int) $id); + $payload = $this->pick($params); + if ((int) ($payload['is_default'] ?? 0) === 1) { + $this->clearDefault(); + } + $payload['updated_at'] = time(); + WxAddressModel::where('id', $row['id'])->update($payload); + return WxAddressModel::where('id', $row['id'])->first()->toArray(); + } + + public function delete($ids): mixed + { + $id = (int) (is_array($ids) ? ($ids[0] ?? 0) : $ids); + $row = $this->owned($id); + WxAddressModel::where('id', $row['id'])->update(['deleted_at' => time(), 'updated_at' => time()]); + return true; + } + + /** + * 设为默认 + */ + public function detail($id): mixed + { + return $this->owned((int) $id); + } + + public function setDefault(int $id): array + { + $row = $this->owned($id); + $this->clearDefault(); + WxAddressModel::where('id', $row['id'])->update(['is_default' => 1, 'updated_at' => time()]); + return WxAddressModel::where('id', $row['id'])->first()->toArray(); + } + + private function owned(int $id): array + { + $row = WxAddressModel::where('id', $id)->where('user_id', $this->userId)->where('deleted_at', 0)->first(); + if (empty($row)) { + $this->utils->errorThrow('地址不存在'); + } + return $row->toArray(); + } + + private function clearDefault(): void + { + WxAddressModel::where('user_id', $this->userId)->where('deleted_at', 0) + ->update(['is_default' => 0, 'updated_at' => time()]); + } + + private function pick(array $params): array + { + $name = trim((string) ($params['name'] ?? '')); + $phone = trim((string) ($params['phone'] ?? '')); + $address = trim((string) ($params['address'] ?? '')); + if ($name === '' || $phone === '' || $address === '') { + $this->utils->errorThrow('请填写姓名、手机和地址'); + } + return [ + 'name' => $name, + 'phone' => $phone, + 'address' => $address, + 'is_default' => (int) ($params['is_default'] ?? 0) === 1 ? 1 : 0, + ]; + } +} diff --git a/app/Service/wx/WxAfterSaleService.php b/app/Service/wx/WxAfterSaleService.php new file mode 100644 index 00000000..729268ac --- /dev/null +++ b/app/Service/wx/WxAfterSaleService.php @@ -0,0 +1,82 @@ +where('user_id', $this->userId) + ->where('deleted_at', 0) + ->orderByDesc('id') + ->get() + ->toArray(); + foreach ($rows as &$row) { + $row['amount_text'] = $price->centsToYuan((int) ($row['amount'] ?? 0)); + $row['order_no'] = $row['order']['order_no'] ?? ''; + $row['status_text'] = match ((int) ($row['status'] ?? 0)) { + AfterSaleModel::STATUS_APPROVED => '已通过', + AfterSaleModel::STATUS_REJECTED => '已拒绝', + default => '待审核', + }; + unset($row['order']); + } + unset($row); + return $rows; + } + + public function create(array $params): array + { + $orderId = (int) ($params['order_id'] ?? 0); + $reason = trim((string) ($params['reason'] ?? '')); + if ($orderId <= 0 || $reason === '') { + $this->utils->errorThrow('请填写订单和原因'); + } + $order = OrderModel::where('id', $orderId)->where('user_id', $this->userId)->where('deleted_at', 0)->first(); + if (empty($order)) { + $this->utils->errorThrow('订单不存在'); + } + $status = (int) $order['status']; + if (!in_array($status, [OrderModel::STATUS_PAID, OrderModel::STATUS_SHIPPED, OrderModel::STATUS_DONE], true)) { + $this->utils->errorThrow('当前订单状态不能申请售后'); + } + $pending = AfterSaleModel::where('order_id', $orderId) + ->where('deleted_at', 0) + ->where('status', AfterSaleModel::STATUS_PENDING) + ->exists(); + if ($pending) { + $this->utils->errorThrow('已有待审核的售后申请'); + } + $amount = (int) ($params['amount'] ?? 0); + if ($amount <= 0) { + $amount = (int) $order['paid_amount']; + } + if ($amount > (int) $order['paid_amount']) { + $this->utils->errorThrow('退款金额不能超过已付金额'); + } + $now = time(); + $id = (int) AfterSaleModel::insertGetId([ + 'order_id' => $orderId, + 'user_id' => $this->userId, + 'reason' => mb_substr($reason, 0, 255), + 'amount' => $amount, + 'images' => is_array($params['images'] ?? null) + ? implode(',', $params['images']) + : (string) ($params['images'] ?? ''), + 'status' => AfterSaleModel::STATUS_PENDING, + 'created_at' => $now, + 'updated_at' => $now, + ]); + return AfterSaleModel::where('id', $id)->first()->toArray(); + } +} diff --git a/app/Service/wx/WxColorcardService.php b/app/Service/wx/WxColorcardService.php new file mode 100644 index 00000000..23faf121 --- /dev/null +++ b/app/Service/wx/WxColorcardService.php @@ -0,0 +1,76 @@ +get('card_class', 0); + $companyId = (int) request()->get('company', 0); + $keyword = trim((string) request()->get('keyword', '')); + $query = ColorcardModel::with(['cardClassInfo', 'companyInfo']) + ->where('deleted_at', 0) + ->where('status', 0) + ->orderByDesc('id'); + if ($classId > 0) { + $query->where('card_class', $classId); + } + if ($companyId > 0) { + $query->where('company', $companyId); + } + if ($keyword !== '') { + $query->where('description', 'like', '%' . $keyword . '%'); + } + $rows = $query->get()->toArray(); + $media = MediaUrlService::getInstance(); + foreach ($rows as &$row) { + $row['cover'] = $media->toPublic($row['cover'] ?? ''); + $row['card_class_name'] = $row['card_class_info']['name'] ?? ''; + $row['company_name'] = $row['company_info']['name'] ?? ''; + unset($row['card_class_info'], $row['company_info']); + } + unset($row); + return $rows; + } + + public function detail(int $id): array + { + $info = ColorcardModel::with(['cardClassInfo', 'companyInfo']) + ->where('id', $id) + ->where('deleted_at', 0) + ->where('status', 0) + ->first(); + if (empty($info)) { + $this->utils->errorThrow('色卡不存在'); + } + $info = $info->toArray(); + $media = MediaUrlService::getInstance(); + $info['cover'] = $media->toPublic($info['cover'] ?? ''); + $info['card_class_name'] = $info['card_class_info']['name'] ?? ''; + $info['company_name'] = $info['company_info']['name'] ?? ''; + unset($info['card_class_info'], $info['company_info']); + return $info; + } + + public function classList(): array + { + return CardClassModel::where('deleted_at', 0)->orderBy('id')->get(['id', 'name'])->toArray(); + } + + public function companyList(): array + { + return CompanyModel::where('deleted_at', 0)->orderBy('id')->get(['id', 'name'])->toArray(); + } +} diff --git a/app/Service/wx/WxFactoryService.php b/app/Service/wx/WxFactoryService.php new file mode 100644 index 00000000..6f8c7188 --- /dev/null +++ b/app/Service/wx/WxFactoryService.php @@ -0,0 +1,74 @@ +get('classification', 0); + $keyword = trim((string) request()->get('keyword', '')); + $query = FactoryInfoModel::with('classificationInfo') + ->where('deleted_at', 0) + ->where('status', 0) + ->orderByDesc('id'); + if ($classId > 0) { + $query->where('classification', $classId); + } + if ($keyword !== '') { + $query->where('name', 'like', '%' . $keyword . '%'); + } + $rows = $query->get()->toArray(); + $media = MediaUrlService::getInstance(); + foreach ($rows as &$row) { + $row['cover'] = $media->toPublic($row['cover'] ?? ''); + $row['classification_name'] = $row['classification_info']['name'] ?? ''; + unset($row['classification_info']); + } + unset($row); + return $rows; + } + + public function detail(int $id): array + { + $info = FactoryInfoModel::with('classificationInfo') + ->where('id', $id) + ->where('deleted_at', 0) + ->where('status', 0) + ->first(); + if (empty($info)) { + $this->utils->errorThrow('工厂不存在'); + } + $info = $info->toArray(); + $media = MediaUrlService::getInstance(); + $info['cover'] = $media->toPublic($info['cover'] ?? ''); + $info['classification_name'] = $info['classification_info']['name'] ?? ''; + unset($info['classification_info']); + $images = FactoryImageModel::where('factory', $id)->where('deleted_at', 0)->orderBy('id')->get()->toArray(); + $media->publicEach($images, ['url']); + $info['images'] = $images; + return $info; + } + + /** + * 工厂分类 + */ + public function classList(): array + { + return FactoryClassificationModel::where('deleted_at', 0) + ->orderBy('id') + ->get(['id', 'name']) + ->toArray(); + } +} diff --git a/app/Service/wx/WxFavoriteService.php b/app/Service/wx/WxFavoriteService.php new file mode 100644 index 00000000..bdae1c84 --- /dev/null +++ b/app/Service/wx/WxFavoriteService.php @@ -0,0 +1,73 @@ +where('user_id', $this->userId) + ->where('deleted_at', 0) + ->orderByDesc('id') + ->get() + ->toArray(); + $media = MediaUrlService::getInstance(); + foreach ($rows as &$row) { + $cat = $row['catalogue'] ?? []; + $row['title'] = $cat['title'] ?? ''; + $row['cover'] = $media->toPublic($cat['cover'] ?? ''); + $row['price'] = $cat['price'] ?? ''; + } + unset($row); + return $rows; + } + + /** + * 切换收藏,返回当前是否已收藏 + */ + public function toggle(int $catalogueId): array + { + if ($catalogueId <= 0) { + $this->utils->errorThrow('参数错误'); + } + $exists = CatalogueModel::where('id', $catalogueId)->where('deleted_at', 0)->exists(); + if (!$exists) { + $this->utils->errorThrow('商品不存在'); + } + $row = FavoriteModel::where('user_id', $this->userId) + ->where('catalogue_id', $catalogueId) + ->where('deleted_at', 0) + ->first(); + if (!empty($row)) { + FavoriteModel::where('id', $row['id'])->update(['deleted_at' => time(), 'updated_at' => time()]); + return ['favorited' => false]; + } + FavoriteModel::insert([ + 'user_id' => $this->userId, + 'catalogue_id' => $catalogueId, + 'created_at' => time(), + 'updated_at' => time(), + ]); + return ['favorited' => true]; + } + + public function isFavorited(int $catalogueId): bool + { + if ($this->userId <= 0 || $catalogueId <= 0) { + return false; + } + return FavoriteModel::where('user_id', $this->userId) + ->where('catalogue_id', $catalogueId) + ->where('deleted_at', 0) + ->exists(); + } +} diff --git a/app/Service/wx/WxFeedbackService.php b/app/Service/wx/WxFeedbackService.php new file mode 100644 index 00000000..4019fe65 --- /dev/null +++ b/app/Service/wx/WxFeedbackService.php @@ -0,0 +1,41 @@ +userId) + ->where('deleted_at', 0) + ->orderByDesc('id') + ->get() + ->toArray(); + } + + public function create(array $params): array + { + $content = trim((string) ($params['content'] ?? '')); + if ($content === '') { + $this->utils->errorThrow('请填写反馈内容'); + } + $now = time(); + $id = (int) FeedbackModel::insertGetId([ + 'user_id' => $this->userId, + 'content' => mb_substr($content, 0, 500), + 'images' => is_array($params['images'] ?? null) + ? implode(',', $params['images']) + : (string) ($params['images'] ?? ''), + 'status' => FeedbackModel::STATUS_PENDING, + 'created_at' => $now, + 'updated_at' => $now, + ]); + return FeedbackModel::where('id', $id)->first()->toArray(); + } +} diff --git a/app/Service/wx/WxListService.php b/app/Service/wx/WxListService.php index 7117162c..95b3a92b 100644 --- a/app/Service/wx/WxListService.php +++ b/app/Service/wx/WxListService.php @@ -191,6 +191,7 @@ class WxListService extends BaseWxService } $priceSheetId = (int) ($params['price_sheet_id'] ?? 0); $quantity = max(1, (int) ($params['quantity'] ?? 1)); + \App\Service\business\StockService::getInstance()->assertAvailable($priceSheetId, $quantity); $exists = ListItemModel::where('list_id', $listId) ->where('catalogue_id', $catalogueId) @@ -242,6 +243,8 @@ class WxListService extends BaseWxService $this->utils->errorThrow('明细不存在'); } $sheetId = (int) ($update['price_sheet_id'] ?? $item['price_sheet_id']); + $qty = (int) ($update['quantity'] ?? $item['quantity']); + \App\Service\business\StockService::getInstance()->assertAvailable($sheetId, $qty); $material = (string) ($update['material_key'] ?? $item['material_key'] ?? 'routine'); if ($sheetId > 0) { $sheet = PriceSheetModel::where('id', $sheetId)->where('deleted_at', 0)->first(); @@ -255,4 +258,13 @@ class WxListService extends BaseWxService } return ListItemModel::where('id', $itemId)->whereIn('list_id', $listIds)->update($update); } + + /** + * 导出本人清单报价,给分享/下载用 + */ + public function exportQuote(int $id): array + { + $info = $this->detail($id); + return \App\Service\common\ExcelCsvService::getInstance()->quoteFromList($info); + } } diff --git a/app/Service/wx/WxMiniService.php b/app/Service/wx/WxMiniService.php index c8111f59..1cc406a2 100644 --- a/app/Service/wx/WxMiniService.php +++ b/app/Service/wx/WxMiniService.php @@ -91,4 +91,32 @@ class WxMiniService $redis->set($app['app_id'], $result['access_token'], max(60, (int) ($result['expires_in'] ?? 7200) - 300)); return (string) $result['access_token']; } + + /** + * 发送订阅消息。模板字段必须与公众平台里的关键词一致,对不上微信会拒收。 + * 这里只尽力发送,失败不抛错,避免挡住收款/发货。 + */ + public function sendSubscribe(string $openId, string $templateId, string $page, array $data): bool + { + if ($openId === '' || $templateId === '') { + return false; + } + try { + $token = $this->accessToken(); + $result = Http::asJson()->post( + $this->baseUrl . 'cgi-bin/message/subscribe/send?access_token=' . $token, + [ + 'touser' => $openId, + 'template_id' => $templateId, + 'page' => $page, + 'data' => $data, + 'miniprogram_state' => 'formal', + 'lang' => 'zh_CN', + ] + )->json(); + return empty($result['errcode']); + } catch (\Throwable) { + return false; + } + } } diff --git a/app/Service/wx/WxPackageService.php b/app/Service/wx/WxPackageService.php index a852915e..6870ae87 100644 --- a/app/Service/wx/WxPackageService.php +++ b/app/Service/wx/WxPackageService.php @@ -140,6 +140,13 @@ class WxPackageService extends BaseWxService if (empty($items)) { $this->utils->errorThrow('套餐还没有搭配商品'); } + $stock = \App\Service\business\StockService::getInstance(); + foreach ($items as $item) { + $stock->assertAvailable( + (int) ($item['price_sheet_id'] ?? 0), + max(1, (int) ($item['quantity'] ?? 1)) + ); + } $listId = (int) ($params['list_id'] ?? 0); $list = null; diff --git a/app/Service/wx/WxProductService.php b/app/Service/wx/WxProductService.php index 5ac8dfbf..2bfbae23 100644 --- a/app/Service/wx/WxProductService.php +++ b/app/Service/wx/WxProductService.php @@ -7,6 +7,7 @@ use App\Models\business\CatalogueModel; use App\Models\business\CategoryModel; use App\Models\business\ImageModel; use App\Service\business\PriceService; +use App\Service\common\MediaUrlService; /** * 小程序商品列表与详情 @@ -16,6 +17,8 @@ use App\Service\business\PriceService; */ class WxProductService extends BaseWxService { + protected bool $needLogin = false; + /** * 商品列表:分类点到二级时按父分类下的所有子分类查 */ @@ -76,12 +79,15 @@ class WxProductService extends BaseWxService $this->utils->errorThrow('商品不存在'); } $info = $info->toArray(); + $media = MediaUrlService::getInstance(); + $info['cover'] = $media->toPublic($info['cover'] ?? ''); + $info['video'] = $media->toPublic($info['video'] ?? ''); // 渲染图作为详情轮播 $info['carousel'] = []; foreach ($info['images'] ?? [] as $image) { if ((int) $image['type'] === ImageModel::TYPE_RENDER) { - $info['carousel'][] = $image['url']; + $info['carousel'][] = $media->toPublic($image['url'] ?? ''); } } @@ -89,6 +95,9 @@ class WxProductService extends BaseWxService $applied = PriceService::getInstance()->applyToRows($info['price_sheet'] ?? [], $showPrice, $this->priceMultiplier()); $info['price_sheet'] = $applied['rows']; $info['is_show_price'] = $applied['is_show_price']; + $info['favorited'] = $this->userId > 0 + ? WxFavoriteService::getInstance()->isFavorited((int) $id) + : false; return $info; } } diff --git a/app/Service/wx/WxThemeService.php b/app/Service/wx/WxThemeService.php index 4c8aa37f..839383df 100644 --- a/app/Service/wx/WxThemeService.php +++ b/app/Service/wx/WxThemeService.php @@ -120,6 +120,8 @@ class WxThemeService extends BaseWxService 'card_schemes' => WxCardSchemeService::mapByCodes($this->collectSchemeCodes($layout)), 'features' => [ 'package_enabled' => WxAppService::getInstance()->packageEnabled(), + 'subscribe_pay_tpl' => $this->subscribeTpl('subscribe_pay_tpl'), + 'subscribe_ship_tpl' => $this->subscribeTpl('subscribe_ship_tpl'), ], 'fallback' => $fallback, ]; @@ -163,4 +165,17 @@ class WxThemeService extends BaseWxService ->orderBy('sort', 'asc') ->get(['code', 'name', 'style_tag', 'preview']); } + + /** + * 当前品牌的订阅模板 ID,给小程序 requestSubscribeMessage 用 + */ + private function subscribeTpl(string $field): string + { + try { + $app = WxAppService::getInstance()->current(); + return trim((string) ($app[$field] ?? '')); + } catch (\Throwable) { + return ''; + } + } } diff --git a/app/Service/wx/WxUserCenterService.php b/app/Service/wx/WxUserCenterService.php index 08d21959..44a91479 100644 --- a/app/Service/wx/WxUserCenterService.php +++ b/app/Service/wx/WxUserCenterService.php @@ -64,4 +64,19 @@ class WxUserCenterService extends BaseWxService ]); return ['nick_name' => $nickName]; } + + /** + * 当前用户绑定的企业资料,给企业详情页 + */ + public function enterprise(): array + { + $user = WxUserModel::with('enterprise')->where('id', $this->userId)->first(); + $ent = $user['enterprise'] ?? null; + if (empty($ent)) { + $this->utils->errorThrow('未绑定企业'); + } + $row = is_array($ent) ? $ent : $ent->toArray(); + $row['logo'] = \App\Service\common\MediaUrlService::getInstance()->toPublic($row['logo'] ?? ''); + return $row; + } } diff --git a/config/lgp_menu.php b/config/lgp_menu.php index 1cf48b78..e5210cf7 100644 --- a/config/lgp_menu.php +++ b/config/lgp_menu.php @@ -66,6 +66,8 @@ return [ ['title' => '企业管理', 'name' => 'CustomerEnterprise', 'path' => '/customer/enterprise', 'component' => '/customer/enterprise/index', 'icon' => 'lucide:building', 'sort' => 2], ['title' => '清单管理', 'name' => 'CustomerList', 'path' => '/customer/list', 'component' => '/customer/list/index', 'icon' => 'lucide:clipboard-list', 'sort' => 3], ['title' => '订单管理', 'name' => 'CustomerOrder', 'path' => '/customer/order', 'component' => '/customer/order/index', 'icon' => 'lucide:receipt-text', 'sort' => 4], + ['title' => '用户反馈', 'name' => 'CustomerFeedback', 'path' => '/customer/feedback', 'component' => '/customer/feedback/index', 'icon' => 'lucide:message-square', 'sort' => 5], + ['title' => '售后管理', 'name' => 'CustomerAfterSale', 'path' => '/customer/after-sale', 'component' => '/customer/after-sale/index', 'icon' => 'lucide:undo-2', 'sort' => 6], ], ], [ diff --git a/config/wx_page_presets.php b/config/wx_page_presets.php index 7dc7261f..d96b241a 100644 --- a/config/wx_page_presets.php +++ b/config/wx_page_presets.php @@ -401,11 +401,11 @@ foreach ($home as &$homeRow) { unset($homeRow); $package = [ - $row('package-00-default', '默认', 'package', [$sec('list', 'card')], 'clay-edge', 'regular', 'none', 'shop', '默认'), - $row('package-01-card', '封面卡片', 'package', [$sec('list', 'card')], 'champagne-foil', 'regular', 'none', 'shop', '套餐'), - $row('package-02-featured', '首图通栏', 'package', [$sec('list', 'featured')], 'walnut-frame', 'regular', 'inset', 'shop', '套餐'), - $row('package-03-magazine', '刊名目录', 'package', [$sec('list', 'magazine')], 'thin-rule', 'airy', 'ornament', 'magazine', '套餐'), - $row('package-04-list', '单列图文', 'package', [$sec('list', 'list')], 'pearl-line', 'compact', 'none', 'shop', '套餐'), + $row('package-00-default', '默认', 'package', [$sec('search', 'bar'), $sec('list', 'card')], 'clay-edge', 'regular', 'none', 'shop', '默认'), + $row('package-01-card', '封面卡片', 'package', [$sec('search', 'bar'), $sec('list', 'card')], 'champagne-foil', 'regular', 'none', 'shop', '套餐'), + $row('package-02-featured', '首图通栏', 'package', [$sec('search', 'bar'), $sec('list', 'featured')], 'walnut-frame', 'regular', 'inset', 'shop', '套餐'), + $row('package-03-magazine', '刊名目录', 'package', [$sec('search', 'pill'), $sec('list', 'magazine')], 'thin-rule', 'airy', 'ornament', 'magazine', '套餐'), + $row('package-04-list', '单列图文', 'package', [$sec('search', 'bar'), $sec('list', 'list')], 'pearl-line', 'compact', 'none', 'shop', '套餐'), ]; return [ diff --git a/database/sql/2026-08-19/01_gap_features.sql b/database/sql/2026-08-19/01_gap_features.sql new file mode 100644 index 00000000..30b65e27 --- /dev/null +++ b/database/sql/2026-08-19/01_gap_features.sql @@ -0,0 +1,167 @@ +-- ============================================================ +-- 缺口补齐:地址簿 / 反馈 / 收藏 / 售后 / 库存 / 视频 / 订阅模板 / 菜单 +-- 幂等;业务表 cc_*,系统表 nl_* +-- ============================================================ + +SET @db := DATABASE(); + +-- 收货地址 +SET @exists := (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'cc_wx_address'); +SET @sql := IF(@exists = 0, + 'CREATE TABLE `cc_wx_address` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `user_id` int NOT NULL DEFAULT 0, + `name` varchar(50) NOT NULL DEFAULT '''', + `phone` varchar(20) NOT NULL DEFAULT '''', + `address` varchar(255) NOT NULL DEFAULT '''', + `is_default` tinyint NOT NULL DEFAULT 0 COMMENT ''1=默认'', + `created_at` int NOT NULL DEFAULT 0, + `updated_at` int NOT NULL DEFAULT 0, + `deleted_at` int NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `idx_addr_user` (`user_id`, `deleted_at`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT=''小程序收货地址''', + 'SELECT ''skip cc_wx_address'' AS msg'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 用户反馈 +SET @exists := (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'cc_feedback'); +SET @sql := IF(@exists = 0, + 'CREATE TABLE `cc_feedback` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `user_id` int NOT NULL DEFAULT 0, + `content` varchar(500) NOT NULL DEFAULT '''', + `images` varchar(1000) NOT NULL DEFAULT '''' COMMENT ''图 URL,逗号分隔'', + `reply` varchar(500) NOT NULL DEFAULT '''', + `status` tinyint NOT NULL DEFAULT 0 COMMENT ''0=待处理 1=已回复'', + `created_at` int NOT NULL DEFAULT 0, + `updated_at` int NOT NULL DEFAULT 0, + `deleted_at` int NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `idx_fb_user` (`user_id`, `status`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT=''用户反馈''', + 'SELECT ''skip cc_feedback'' AS msg'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 收藏 +SET @exists := (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'cc_favorite'); +SET @sql := IF(@exists = 0, + 'CREATE TABLE `cc_favorite` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `user_id` int NOT NULL DEFAULT 0, + `catalogue_id` int NOT NULL DEFAULT 0, + `created_at` int NOT NULL DEFAULT 0, + `updated_at` int NOT NULL DEFAULT 0, + `deleted_at` int NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `idx_fav_user` (`user_id`, `deleted_at`), + KEY `idx_fav_cat` (`catalogue_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT=''商品收藏''', + 'SELECT ''skip cc_favorite'' AS msg'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 售后 +SET @exists := (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'cc_after_sale'); +SET @sql := IF(@exists = 0, + 'CREATE TABLE `cc_after_sale` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `order_id` int NOT NULL DEFAULT 0, + `user_id` int NOT NULL DEFAULT 0, + `reason` varchar(255) NOT NULL DEFAULT '''', + `amount` int NOT NULL DEFAULT 0 COMMENT ''申请退款金额(分)'', + `images` varchar(1000) NOT NULL DEFAULT '''', + `status` tinyint NOT NULL DEFAULT 0 COMMENT ''0=待审 1=通过 2=拒绝'', + `admin_remark` varchar(255) NOT NULL DEFAULT '''', + `created_at` int NOT NULL DEFAULT 0, + `updated_at` int NOT NULL DEFAULT 0, + `deleted_at` int NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `idx_as_order` (`order_id`), + KEY `idx_as_user` (`user_id`, `status`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT=''售后申请''', + 'SELECT ''skip cc_after_sale'' AS msg'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 商品视频 +SET @col := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'cc_catalogue' AND COLUMN_NAME = 'video'); +SET @sql := IF(@col = 0, + 'ALTER TABLE `cc_catalogue` ADD COLUMN `video` varchar(500) NOT NULL DEFAULT '''' COMMENT ''商品视频'' AFTER `pdf`', + 'SELECT ''skip cc_catalogue.video'' AS msg'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 规格库存:-1=不限 +SET @col := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'cc_price_sheet' AND COLUMN_NAME = 'stock'); +SET @sql := IF(@col = 0, + 'ALTER TABLE `cc_price_sheet` ADD COLUMN `stock` int NOT NULL DEFAULT -1 COMMENT ''库存,-1不限'' AFTER `routine`', + 'SELECT ''skip cc_price_sheet.stock'' AS msg'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 订阅消息模板 +SET @col := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'nl_wx_app' AND COLUMN_NAME = 'subscribe_pay_tpl'); +SET @sql := IF(@col = 0, + 'ALTER TABLE `nl_wx_app` ADD COLUMN `subscribe_pay_tpl` varchar(64) NOT NULL DEFAULT '''' COMMENT ''付款成功订阅模板'' AFTER `package_enabled`', + 'SELECT ''skip nl_wx_app.subscribe_pay_tpl'' AS msg'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @col := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'nl_wx_app' AND COLUMN_NAME = 'subscribe_ship_tpl'); +SET @sql := IF(@col = 0, + 'ALTER TABLE `nl_wx_app` ADD COLUMN `subscribe_ship_tpl` varchar(64) NOT NULL DEFAULT '''' COMMENT ''发货订阅模板'' AFTER `subscribe_pay_tpl`', + 'SELECT ''skip nl_wx_app.subscribe_ship_tpl'' AS msg'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 套餐菜单(若 lgp:menu-sync 未跑过) +SET @parent_id := (SELECT id FROM nl_menu WHERE name = 'Goods' AND deleted_at = 0 LIMIT 1); +SET @exists := (SELECT COUNT(*) FROM nl_menu WHERE name = 'GoodsPackage' AND deleted_at = 0); +SET @sql := IF(@parent_id IS NOT NULL AND @exists = 0, + CONCAT( + 'INSERT INTO `nl_menu` (`title`,`icon`,`name`,`path`,`component`,`redirect`,`keep_alive`,`hide_in_menu`,`affix_tab`,`badge`,`badge_type`,`badge_variants`,`iframe_src`,`pid`,`sort`,`query`,`created_at`,`updated_at`,`deleted_at`) VALUES (', + '''套餐搭配'', ''lucide:package'', ''GoodsPackage'', ''/goods/package'', ''/goods/package/index'', '''', 1, 0, 1, '''', 0, 0, '''', ', + @parent_id, ', 6, '''', UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0)' + ), + 'SELECT ''skip menu GoodsPackage'' AS msg'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 反馈 / 售后菜单 +SET @parent_id := (SELECT id FROM nl_menu WHERE name = 'Customer' AND deleted_at = 0 LIMIT 1); +SET @exists := (SELECT COUNT(*) FROM nl_menu WHERE name = 'CustomerFeedback' AND deleted_at = 0); +SET @sql := IF(@parent_id IS NOT NULL AND @exists = 0, + CONCAT( + 'INSERT INTO `nl_menu` (`title`,`icon`,`name`,`path`,`component`,`redirect`,`keep_alive`,`hide_in_menu`,`affix_tab`,`badge`,`badge_type`,`badge_variants`,`iframe_src`,`pid`,`sort`,`query`,`created_at`,`updated_at`,`deleted_at`) VALUES (', + '''用户反馈'', ''lucide:message-square'', ''CustomerFeedback'', ''/customer/feedback'', ''/customer/feedback/index'', '''', 1, 0, 1, '''', 0, 0, '''', ', + @parent_id, ', 5, '''', UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0)' + ), + 'SELECT ''skip menu CustomerFeedback'' AS msg'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @exists := (SELECT COUNT(*) FROM nl_menu WHERE name = 'CustomerAfterSale' AND deleted_at = 0); +SET @sql := IF(@parent_id IS NOT NULL AND @exists = 0, + CONCAT( + 'INSERT INTO `nl_menu` (`title`,`icon`,`name`,`path`,`component`,`redirect`,`keep_alive`,`hide_in_menu`,`affix_tab`,`badge`,`badge_type`,`badge_variants`,`iframe_src`,`pid`,`sort`,`query`,`created_at`,`updated_at`,`deleted_at`) VALUES (', + '''售后管理'', ''lucide:undo-2'', ''CustomerAfterSale'', ''/customer/after-sale'', ''/customer/after-sale/index'', '''', 1, 0, 1, '''', 0, 0, '''', ', + @parent_id, ', 6, '''', UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0)' + ), + 'SELECT ''skip menu CustomerAfterSale'' AS msg'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 有微信用户菜单的角色同步新菜单 +SET @wx_user_menu_id := (SELECT id FROM nl_menu WHERE name = 'CustomerWxUser' AND deleted_at = 0 LIMIT 1); +SET @menu_id := (SELECT id FROM nl_menu WHERE name = 'CustomerFeedback' AND deleted_at = 0 LIMIT 1); +INSERT INTO `nl_role_menu_relations` (`role_id`, `menu_id`, `created_at`) +SELECT r.role_id, @menu_id, UNIX_TIMESTAMP() +FROM `nl_role_menu_relations` r +WHERE @menu_id IS NOT NULL AND @wx_user_menu_id IS NOT NULL AND r.menu_id = @wx_user_menu_id + AND NOT EXISTS (SELECT 1 FROM `nl_role_menu_relations` x WHERE x.role_id = r.role_id AND x.menu_id = @menu_id); + +SET @menu_id := (SELECT id FROM nl_menu WHERE name = 'CustomerAfterSale' AND deleted_at = 0 LIMIT 1); +INSERT INTO `nl_role_menu_relations` (`role_id`, `menu_id`, `created_at`) +SELECT r.role_id, @menu_id, UNIX_TIMESTAMP() +FROM `nl_role_menu_relations` r +WHERE @menu_id IS NOT NULL AND @wx_user_menu_id IS NOT NULL AND r.menu_id = @wx_user_menu_id + AND NOT EXISTS (SELECT 1 FROM `nl_role_menu_relations` x WHERE x.role_id = r.role_id AND x.menu_id = @menu_id); + +SET @cat_menu := (SELECT id FROM nl_menu WHERE name = 'GoodsCatalogue' AND deleted_at = 0 LIMIT 1); +SET @pkg_menu := (SELECT id FROM nl_menu WHERE name = 'GoodsPackage' AND deleted_at = 0 LIMIT 1); +INSERT INTO `nl_role_menu_relations` (`role_id`, `menu_id`, `created_at`) +SELECT r.role_id, @pkg_menu, UNIX_TIMESTAMP() +FROM `nl_role_menu_relations` r +WHERE @pkg_menu IS NOT NULL AND @cat_menu IS NOT NULL AND r.menu_id = @cat_menu + AND NOT EXISTS (SELECT 1 FROM `nl_role_menu_relations` x WHERE x.role_id = r.role_id AND x.menu_id = @pkg_menu); diff --git a/public/nl_admin.sql b/public/nl_admin.sql index 8ae766a4..eb79aa27 100644 --- a/public/nl_admin.sql +++ b/public/nl_admin.sql @@ -867,6 +867,8 @@ CREATE TABLE `nl_wx_app` ( `notify_url` varchar(255) NOT NULL DEFAULT '' COMMENT '支付回调地址', `template_code` varchar(50) NOT NULL DEFAULT '' COMMENT '默认装修模板 code', `package_enabled` tinyint NOT NULL DEFAULT 0 COMMENT '1=启用套餐(首页热门+底栏)', + `subscribe_pay_tpl` varchar(64) NOT NULL DEFAULT '' COMMENT '付款成功订阅模板', + `subscribe_ship_tpl` varchar(64) NOT NULL DEFAULT '' COMMENT '发货订阅模板', `status` tinyint NOT NULL DEFAULT 0 COMMENT '0=启用 1=停用', `remark` varchar(255) NOT NULL DEFAULT '', `created_at` int NOT NULL DEFAULT 0, diff --git a/routes/api.php b/routes/api.php index 5ee9b291..2457119f 100644 --- a/routes/api.php +++ b/routes/api.php @@ -37,16 +37,22 @@ Route::group(['prefix' => 'wx', 'middleware' => 'nl.wx.https'], function () { 'auth' => \App\Http\Controllers\Wx\AuthController::class, // 小程序登录 'home' => \App\Http\Controllers\Wx\HomeController::class, // 首页轮播与分类 'package' => \App\Http\Controllers\Wx\PackageController::class, // 套餐热门/列表/详情/一件加入 + 'product' => \App\Http\Controllers\Wx\ProductController::class, // 商品浏览免登录(和老 wx-api 一致) + 'factory' => \App\Http\Controllers\Wx\FactoryController::class, // 工厂目录 + 'colorcard' => \App\Http\Controllers\Wx\ColorcardController::class, // 色卡目录 '' => \App\Http\Controllers\Wx\ThemeController::class, // 主题下发与支付回调 ]); Route::group(['middleware' => 'nl.wx'], function () { UtilsService::class::getInstance()->autoRouteRegister([ - 'product' => \App\Http\Controllers\Wx\ProductController::class, // 商品 'list' => \App\Http\Controllers\Wx\ListController::class, // 清单 'user' => \App\Http\Controllers\Wx\UserController::class, // 我的 'order' => \App\Http\Controllers\Wx\OrderController::class, // 订单 'upload' => \App\Http\Controllers\Wx\WxUploadController::class, // 上传(转账凭证等) + 'address' => \App\Http\Controllers\Wx\AddressController::class, // 收货地址 + 'feedback' => \App\Http\Controllers\Wx\FeedbackController::class, // 用户反馈 + 'favorite' => \App\Http\Controllers\Wx\FavoriteController::class, // 收藏 + 'after-sale' => \App\Http\Controllers\Wx\AfterSaleController::class, // 售后 ]); }); }); @@ -91,6 +97,8 @@ Route::group(['middleware' => 'nl.auth'], function () { 'enterprise' => \App\Http\Controllers\Api\EnterpriseController::class, // 企业管理 'list' => \App\Http\Controllers\Api\ListController::class, // 清单管理 'order' => \App\Http\Controllers\Api\OrderController::class, // 订单管理 + 'feedback' => \App\Http\Controllers\Api\FeedbackController::class, // 用户反馈 + 'after-sale' => \App\Http\Controllers\Api\AfterSaleController::class, // 售后审核 'wx-template' => \App\Http\Controllers\Api\WxTemplateController::class, // 小程序装修模板 'wx-card-scheme' => \App\Http\Controllers\Api\WxCardSchemeController::class, // 卡片画布方案 'wx-app' => \App\Http\Controllers\Api\WxAppController::class, // 小程序应用配置