From 6ab4505e17f8a8017ef5c3a37e29597bfd92381b Mon Sep 17 00:00:00 2001 From: mzl Date: Fri, 7 Nov 2025 15:27:16 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E5=AF=B9=E8=B4=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../FinanceReconDubaiFilesController.php | 33 ++ app/command/FinanceRecoCommand.php | 32 ++ app/common/controller/UploadController.php | 4 +- .../logic/FinanceReconDubaiFilesLogic.php | 17 + app/common/model/FinanceReconDubaiFiles.php | 49 +++ .../service/FinanceReconDubaiFilesService.php | 290 +++++++++++++++++ app/common/service/UploadService.php | 2 +- app/common/utils/excel/ExcelImportTrait.php | 133 ++++++++ app/common/utils/excel/ExcelReader.php | 292 ++++++++++++++++++ .../FinanceReconDubaiFilesValidate.php | 29 ++ app/controller/IndexController.php | 26 +- config/oss.php | 10 + config/route.php | 10 + 13 files changed, 917 insertions(+), 10 deletions(-) create mode 100644 app/admin/controller/FinanceReconDubaiFilesController.php create mode 100644 app/command/FinanceRecoCommand.php create mode 100644 app/common/logic/FinanceReconDubaiFilesLogic.php create mode 100644 app/common/model/FinanceReconDubaiFiles.php create mode 100644 app/common/service/FinanceReconDubaiFilesService.php create mode 100644 app/common/utils/excel/ExcelImportTrait.php create mode 100644 app/common/utils/excel/ExcelReader.php create mode 100644 app/common/validate/FinanceReconDubaiFilesValidate.php create mode 100644 config/oss.php diff --git a/app/admin/controller/FinanceReconDubaiFilesController.php b/app/admin/controller/FinanceReconDubaiFilesController.php new file mode 100644 index 0000000..cd85dce --- /dev/null +++ b/app/admin/controller/FinanceReconDubaiFilesController.php @@ -0,0 +1,33 @@ +service = new FinanceReconDubaiFilesService(); + } + + /** + * 列表页数据格式化 + * + * @param array $data + * @return array + */ + // protected function indexFormat($data) + // { + // if (isset($data['created_at'])) + // $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']); + // if (isset($data['updated_at'])) + // $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']); + // return $data; + // } +} \ No newline at end of file diff --git a/app/command/FinanceRecoCommand.php b/app/command/FinanceRecoCommand.php new file mode 100644 index 0000000..1406ab9 --- /dev/null +++ b/app/command/FinanceRecoCommand.php @@ -0,0 +1,32 @@ +generatePrivateUrl('fdesk_test/20251104/20251104_财务.xlsx');; + + $service = new \app\common\service\FinanceReconDubaiFilesService(); + $result = $service->dImport('fdesk_test/20251104/20251104_迪拜.xlsx'); + + return self::SUCCESS; + } +} \ No newline at end of file diff --git a/app/common/controller/UploadController.php b/app/common/controller/UploadController.php index a025666..800090a 100644 --- a/app/common/controller/UploadController.php +++ b/app/common/controller/UploadController.php @@ -91,7 +91,7 @@ class UploadController $mimes = $ossservice->getMimeTypes(['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'zip', 'rar']); $result = Storage::adapter('oss') - ->path('x_server/' . $sceneUrl . date('Ymd')) + ->path(config('oss.oss_main_dir') . $sceneUrl . date('Ymd')) ->size(1024*1024*5) ->extYes($mimes) ->upload($file); @@ -132,7 +132,7 @@ class UploadController } $result = Storage::adapter('oss') - ->path('x_server/' . $sceneUrl . date('Ymd')) + ->path(config('oss.oss_main_dir') . $sceneUrl . date('Ymd')) ->size(1024*1024*5) ->extYes(['image/jpeg','image/png']) ->extNo(['image/gif']) diff --git a/app/common/logic/FinanceReconDubaiFilesLogic.php b/app/common/logic/FinanceReconDubaiFilesLogic.php new file mode 100644 index 0000000..360f23f --- /dev/null +++ b/app/common/logic/FinanceReconDubaiFilesLogic.php @@ -0,0 +1,17 @@ +generatePrivateUrl() 完成 + */ +class FinanceReconDubaiFilesService +{ + use CrudServiceTrait; + use ExcelImportTrait; // 复用通用导入方法 + + /** 指定当前要操作的模型(用于 CrudServiceTrait) */ + protected string $modelClass = FinanceReconDubaiFiles::class; + + /** 验证器 */ + protected $validator = FinanceReconDubaiFilesValidate::class; + + /** 获取验证器实例 */ + public function getValidator() + { + return new $this->validator; + } + + /** + * 写入前置:将上传组件的结构转为 *_url / *_name,并记录创建人 + */ + public function beforeServiceStore($data, Request $request) + { + $data['created_by'] = $request->user->uid ?? 0; + $data['caiwu_file_url'] = $data['caiwu_file']['url'] ?? ''; + $data['caiwu_file_name'] = $data['caiwu_file']['name'] ?? ''; + $data['dubai_file_url'] = $data['dubai_file']['url'] ?? ''; + $data['dubai_file_name'] = $data['dubai_file']['name'] ?? ''; + return $data; + } + + /* ============================================================ + * 一、M项目对账明细 导入配置(sheet: M项目对账明细) + * ============================================================ */ + + /** 中文表头 => 目标字段名 */ + protected array $headerMapM = [ + '车架号' => 'vin', + '开票价' => 'invoice_price', + '实际采购价' => 'actual_purchase_price', + '开票价和实际采购价差额' => 'invoice_vs_purchase_diff', + '车源中介费' => 'source_broker_fee', + '配件' => 'parts_fee', + '停车费' => 'parking_fee', + '保险费' => 'insurance_fee', + '车辆手续费' => 'vehicle_service_fee', + '检测费' => 'inspection_fee', + '购置税' => 'purchase_tax', + '汽车其他费用' => 'other_vehicle_fee', + '转移待出口' => 'transfer_to_export', + '国际运费' => 'freight_international', + '国内运费1' => 'freight_domestic_1', + '国内运费2' => 'freight_domestic_2', + '报关费及港杂费' => 'customs_port_fee', + '注销费' => 'cancellation_fee', + '车船税' => 'vehicle_vessel_tax', + '其他财务费用' => 'other_finance_fee', + '汽车小额杂费' => 'minor_vehicle_misc', + '上牌费' => 'registration_fee', + '其他保险费' => 'other_insurance_fee', + '油补' => 'fuel_subsidy', + '快递费' => 'express_fee', + '国内成本合计' => 'domestic_cost_total', + '退税额' => 'tax_refund_amount', + 'cif杰贝阿里成本价' => 'cif_jebel_ali_cost', + '范经理发车看板CIF' => 'fan_manager_cif', + '财务-范经理 差额' => 'finance_vs_fan_diff', + ]; + + /** 需要转为 decimal 的目标字段 */ + protected array $decimalFieldsM = [ + 'invoice_price','actual_purchase_price','invoice_vs_purchase_diff', + 'source_broker_fee','parts_fee','parking_fee','insurance_fee','vehicle_service_fee','inspection_fee', + 'purchase_tax','other_vehicle_fee','transfer_to_export', + 'freight_international','freight_domestic_1','freight_domestic_2','customs_port_fee', + 'cancellation_fee','vehicle_vessel_tax','other_finance_fee','minor_vehicle_misc', + 'registration_fee','other_insurance_fee','fuel_subsidy','express_fee', + 'domestic_cost_total','tax_refund_amount','cif_jebel_ali_cost','fan_manager_cif','finance_vs_fan_diff', + ]; + + /** + * M项目对账明细导入 + * + * @param string $file 可为“签名 URL”或“OSS 对象键” + * @param bool $isUrl 若传入的是已签名 URL,请设 true;若是对象键,设 false + * @param bool $insertToDb 是否立即入库 + * + * 返回:['sheet','total_rows','skipped','errors','to_import','inserted','preview_3',('error'?)] + */ + public function mImport(string $file, bool $isUrl = true, bool $insertToDb = true): array + { + $sheet = 'M项目对账明细'; + + // 允许传对象键:当 isUrl=false 且非 http(s) 时,自动用 OssService 签名 + if ($isUrl === false && !preg_match('#^https?://#i', $file)) { + $file = $this->ossSignedUrl($file); + $isUrl = true; + } + + $rows = $this->readSheetRows($file, $sheet, $isUrl, headerRow: 1); + $mapped = []; + $skipped = 0; + $errors = 0; + + foreach ($rows as $row) { + try { + if ($this->shouldSkipByVinAliases($row, ['车架号'])) { $skipped++; continue; } + $rec = $this->mapAndNormalizeRow($row, $this->headerMapM, $this->decimalFieldsM); + $this->stamp($rec); + $mapped[] = $rec; + } catch (\Throwable $e) { + // 单行异常不阻断全局导入 + $errors++; continue; + } + } + + $result = [ + 'sheet' => $sheet, + 'total_rows' => count($rows), + 'skipped' => $skipped, + 'errors' => $errors, + 'to_import' => count($mapped), + ]; + + if ($insertToDb) { + // 注意表名:按你的业务这里写入 finance_recon_m_detail + $ret = $this->batchInsert('finance_recon_m_detail', $mapped); + $result['inserted'] = $ret['inserted']; + if ($ret['error']) $result['error'] = 'Database insert error: ' . $ret['error']; + } else { + $result['inserted'] = 0; + } + + $result['preview_3'] = array_slice($mapped, 0, 3); + return $result; + } + + /** M项目对账明细:从 OSS 对象键导入(语义化便捷入口) */ + public function mImportFromOssKey(string $objectKey, bool $insertToDb = true): array + { + $signedUrl = $this->ossSignedUrl($objectKey); + return $this->mImport($signedUrl, true, $insertToDb); + } + + /** M项目对账明细:从已签名 URL 导入(语义化便捷入口) */ + public function mImportFromSignedUrl(string $signedUrl, bool $insertToDb = true): array + { + return $this->mImport($signedUrl, true, $insertToDb); + } + + /* ============================================================ + * 二、迪拜两表合并 导入配置(sheets: Stock in Jebel Ali / Under shipping) + * ============================================================ */ + + /** 表头映射(已兼容不一致列名:VIN NO/车架号、国际运费和杂费/SHIPPING COST) */ + protected array $headerMapDubai = [ + 'VIN NO' => 'vin', '车架号' => 'vin', + '开票价' => 'invoice_price', + '中介费' => 'broker_fee', + '购置税' => 'purchase_tax', + '退税' => 'tax_refund', + '不含税车价' => 'price_ex_vat', + '费用' => 'fee_total', + '国内运费' => 'domestic_freight', + 'FCA成本价' => 'fca_cost', + 'FCA成本价汇率' => 'fca_fx', + '国际运费和杂费' => 'shipping_cost', + 'SHIPPING COST' => 'shipping_cost', + '运费汇率' => 'shipping_fx', + 'CIF 杰贝阿里成本价' => 'cif_jebel_ali_cost', + 'CIF RMB' => 'cif_rmb', // Under shipping 没有该列,将在逻辑中推导 + ]; + + /** 需要转为 decimal 的目标字段(汇率字段使用 DECIMAL(18,6) 建表) */ + protected array $decimalFieldsDubai = [ + 'invoice_price','broker_fee','purchase_tax','tax_refund','price_ex_vat', + 'fee_total','domestic_freight','fca_cost','fca_fx', + 'shipping_cost','shipping_fx','cif_jebel_ali_cost','cif_rmb', + ]; + + /** + * 迪拜两表合并导入(Stock in Jebel Ali / Under shipping) + * + * 缺失值规则: + * - Under shipping 无 “CIF RMB”,尝试:cif_rmb = cif_jebel_ali_cost × shipping_fx + * 若运费汇率缺失则用 fca_fx 兜底,都没有则置 NULL。 + * + * 返回:['sheets','total_rows','skipped','to_import','inserted','preview_5',('error'?)] + */ + public function dImport(string $file, bool $isUrl = false, bool $insertToDb = true): array + { + // 允许传对象键:当 isUrl=false 且非 http(s) 时,自动用 OssService 签名 + if ($isUrl === false && !preg_match('#^https?://#i', $file)) { + $file = $this->ossSignedUrl($file); + $isUrl = true; + } + + $sheets = ['Stock in Jebel Ali', 'Under shipping']; + $parsed = []; + + // 逐 Sheet 读取 + foreach ($sheets as $sheet) { + $parsed[$sheet] = $this->readSheetRows($file, $sheet, $isUrl, headerRow: 1); + } + + // 行处理与合并 + $rowsAll = []; + $skipped = 0; + + foreach ($sheets as $sheet) { + foreach ($parsed[$sheet] as $row) { + if ($this->shouldSkipByVinAliases($row, ['VIN NO','车架号'])) { $skipped++; continue; } + + // 映射 + 数值规范化 + $rec = $this->mapAndNormalizeRow($row, $this->headerMapDubai, $this->decimalFieldsDubai); + + // Under shipping 无 CIF RMB:尝试用汇率推导 + if (empty($rec['cif_rmb'])) { + $cif = $rec['cif_jebel_ali_cost'] ?? null; + $fx = $rec['shipping_fx'] ?? null; + if ($cif !== null && $fx !== null) { + $rec['cif_rmb'] = round($cif * $fx, 2); + } elseif ($cif !== null && !empty($rec['fca_fx'])) { + $rec['cif_rmb'] = round($cif * (float)$rec['fca_fx'], 2); + } else { + $rec['cif_rmb'] = null; + } + } + + // 统一 VIN 与来源 + $rec['vin'] = $this->pickFirstNonEmpty($row, ['VIN NO','车架号']); + $rec['source_sheet'] = $sheet; + + $this->stamp($rec); + $rowsAll[] = $rec; + } + } + + // 结果与入库 + $result = [ + 'sheets' => $sheets, + 'total_rows' => array_sum(array_map('count', $parsed)), + 'skipped' => $skipped, + 'to_import' => count($rowsAll), + ]; + + if ($insertToDb) { + // 注意表名:按你的业务这里写入 finance_recon_d_detail + $ret = $this->batchInsert('finance_recon_d_detail', $rowsAll); + $result['inserted'] = $ret['inserted']; + if ($ret['error']) $result['error'] = 'Database insert error: ' . $ret['error']; + } else { + $result['inserted'] = 0; + } + + $result['preview_5'] = array_slice($rowsAll, 0, 5); + return $result; + } + + /** 迪拜两表:从 OSS 对象键导入(语义化便捷入口) */ + public function dImportFromOssKey(string $objectKey, bool $insertToDb = true): array + { + $signedUrl = $this->ossSignedUrl($objectKey); + return $this->dImport($signedUrl, true, $insertToDb); + } + + /** 迪拜两表:从已签名 URL 导入(语义化便捷入口) */ + public function dImportFromSignedUrl(string $signedUrl, bool $insertToDb = true): array + { + return $this->dImport($signedUrl, true, $insertToDb); + } +} \ No newline at end of file diff --git a/app/common/service/UploadService.php b/app/common/service/UploadService.php index 43684cf..62b838c 100644 --- a/app/common/service/UploadService.php +++ b/app/common/service/UploadService.php @@ -50,7 +50,7 @@ class UploadService $size = filesize($filePath) ?: 0; $sceneUrl = $scene ? ($scene . '/') : ''; - $dir = 'x_server/' . $sceneUrl . date('Ymd') . '/'; + $dir = config('oss.oss_main_dir') . $sceneUrl . date('Ymd') . '/'; // 使用传入的 original_name 命名(缺少扩展名时补上推断的扩展名),并做简单安全处理 $safeBaseName = preg_replace('/[\\\\\/]+/', '_', $originalName); if ($extension && strtolower(pathinfo($safeBaseName, PATHINFO_EXTENSION)) !== strtolower($extension)) { diff --git a/app/common/utils/excel/ExcelImportTrait.php b/app/common/utils/excel/ExcelImportTrait.php new file mode 100644 index 0000000..79fffa4 --- /dev/null +++ b/app/common/utils/excel/ExcelImportTrait.php @@ -0,0 +1,133 @@ +ossSignedUrl($objectKey); + $ret = ExcelReader::readSheetFromUrl($signedUrl, $sheet, $headerRow, $startRow); + return $ret['rows'] ?? []; + } + + /** + * 使用你的 OssService 生成私有签名 URL + * - 兼容你的现有写法:(new \app\common\service\OssService)->generatePrivateUrl($objectKey) + */ + protected function ossSignedUrl(string $objectKey): string + { + return (new \app\common\service\OssService())->generatePrivateUrl($objectKey); + } + + /** + * 将一行做字段映射 + 基础类型规范化(decimal/bool/date) + * - $headerMap:表头映射(源列名 => 目标字段名) + * - $decimalFields/$boolFields/$dateFields:需要做类型规范化的目标字段名列表 + */ + protected function mapAndNormalizeRow( + array $row, + array $headerMap, + array $decimalFields = [], + array $boolFields = [], + array $dateFields = [] + ): array { + $rec = ExcelReader::remap($row, $headerMap); + + foreach ($decimalFields as $f) { + if (array_key_exists($f, $rec)) { + $rec[$f] = ExcelReader::toDecimal($rec[$f]); // 建议已升级为“去货币符号/千分位/括号负数”的版本 + } + } + foreach ($boolFields as $f) { + if (array_key_exists($f, $rec)) { + $rec[$f] = ExcelReader::toBool($rec[$f]); + } + } + foreach ($dateFields as $f) { + if (array_key_exists($f, $rec)) { + $rec[$f] = ExcelReader::toDate($rec[$f]); + } + } + return $rec; + } + + /** + * 基于 VIN 的跳过规则:VIN 为空、或是“合计/总计”行 + * - $vinAliases:VIN 列的候选表头(如 ['车架号','VIN NO']) + */ + protected function shouldSkipByVinAliases(array $row, array $vinAliases = ['车架号', 'VIN NO']): bool + { + $vin = $this->pickFirstNonEmpty($row, $vinAliases); + if ($vin === null) return true; + return (bool)preg_match('/^(合计|总计)/u', $vin); + } + + /** + * 在多个候选键里取第一个非空字符串 + */ + protected function pickFirstNonEmpty(array $row, array $keys): ?string + { + foreach ($keys as $k) { + if (isset($row[$k])) { + $v = trim((string)$row[$k]); + if ($v !== '') return $v; + } + } + return null; + } + + /** + * 统一补齐 created_at / updated_at 时间戳(int(11)) + */ + protected function stamp(array &$rec): void + { + $now = time(); + $rec['created_at'] = $now; + $rec['updated_at'] = $now; + } + + /** + * 批量入库 + * - 返回 ['inserted'=>N, 'error'=>null|string] + * - 发生异常时不抛出,返回 error 字符串,便于上层统一处理 + */ + protected function batchInsert(string $table, array $rows): array + { + if (!$rows) return ['inserted' => 0, 'error' => null]; + try { + Db::table($table)->insert($rows); + return ['inserted' => count($rows), 'error' => null]; + } catch (\Throwable $e) { + return ['inserted' => 0, 'error' => $e->getMessage()]; + } + } +} diff --git a/app/common/utils/excel/ExcelReader.php b/app/common/utils/excel/ExcelReader.php new file mode 100644 index 0000000..e5f002a --- /dev/null +++ b/app/common/utils/excel/ExcelReader.php @@ -0,0 +1,292 @@ +[], 'rows'=>[]] + * - $headerRow:从 1 开始计数(Excel 行号) + * - $startRow:数据起始行(默认 headerRow+1) + * - 注意:$filePath 必须是“本地文件路径”;若为 URL,请改用 readSheetFromUrl() + */ + public static function readSheet(string $filePath, string $sheetName, int $headerRow = 1, ?int $startRow = null): array + { + self::assertRequirements(); + + if (!is_file($filePath)) { + throw new \RuntimeException("File not found: {$filePath}"); + } + + $reader = IOFactory::createReaderForFile($filePath); + $reader->setReadDataOnly(true); + $spreadsheet = $reader->load($filePath); + + $sheet = $spreadsheet->getSheetByName($sheetName); + if (!$sheet) { + $names = implode(' / ', $spreadsheet->getSheetNames()); + $spreadsheet->disconnectWorksheets(); + throw new \RuntimeException("Sheet '{$sheetName}' not found. Available: {$names}"); + } + + $highestRow = $sheet->getHighestDataRow(); + $highestCol = $sheet->getHighestDataColumn(); + $highestColIndex = Coordinate::columnIndexFromString($highestCol); + + // 读取表头(去除可能的 UTF-8 BOM) + $headers = []; + for ($col = 1; $col <= $highestColIndex; $col++) { + $coord = Coordinate::stringFromColumnIndex($col) . $headerRow; + $cell = $sheet->getCell($coord); + $raw = $cell ? $cell->getCalculatedValue() : null; + $text = is_null($raw) ? '' : trim((string)$raw); + // 去掉 BOM + if ($col === 1) { + $text = preg_replace('/^\xEF\xBB\xBF/u', '', $text); + } + $headers[$col] = $text === '' ? "COL_{$col}" : $text; + } + + // 读取数据 + $rows = []; + $firstDataRow = $startRow ?? ($headerRow + 1); + for ($row = $firstDataRow; $row <= $highestRow; $row++) { + $record = []; + $isEmpty = true; + + for ($col = 1; $col <= $highestColIndex; $col++) { + $coord = Coordinate::stringFromColumnIndex($col) . $row; + $cell = $sheet->getCell($coord); + $value = $cell ? $cell->getCalculatedValue() : null; + + // 日期单元格转换(使用 PhpSpreadsheet 的日期检测) + if ($cell && \PhpOffice\PhpSpreadsheet\Shared\Date::isDateTime($cell) && is_numeric($value)) { + $dt = \PhpOffice\PhpSpreadsheet\Shared\Date::excelToDateTimeObject($value); + $value = $dt ? $dt->format('Y-m-d') : $value; + } + + if (is_string($value)) { + $value = trim($value); + } + if ($value !== null && $value !== '') { + $isEmpty = false; + } + $record[$headers[$col]] = $value; + } + + if (!$isEmpty) { + $rows[] = $record; + } + } + + $spreadsheet->disconnectWorksheets(); + return ['headers' => array_values($headers), 'rows' => $rows]; + } + + /** + * 从 URL 读取指定 sheet(内部先下载到临时文件) + */ + public static function readSheetFromUrl(string $url, string $sheetName, int $headerRow = 1, ?int $startRow = null): array + { + $tmp = self::downloadToTemp($url); + try { + return self::readSheet($tmp, $sheetName, $headerRow, $startRow); + } finally { + @unlink($tmp); + } + } + + /** + * 按映射表将“源表头”=>“目标字段名”(snake_case) + * $map 形如:['车架号' => 'vin', '实际采购价' => 'actual_purchase_price', ...] + */ + public static function remap(array $row, array $map): array + { + $out = []; + foreach ($map as $from => $to) { + $out[$to] = $row[$from] ?? null; + } + return $out; + } + + /** 将一批行做 remap */ + public static function remapRows(array $rows, array $map): array + { + $out = []; + foreach ($rows as $row) { + $out[] = self::remap($row, $map); + } + return $out; + } + + /** + * 将“金额字符串”解析成 float(适配多币种/多地区写法) + * - 支持:¥ ¥ $ € £ …、AED/Dirham:dh / dhs / aed、阿语 د.إ + * - 支持:中文/全角标点、阿拉伯小数/千分位、细空格、撇号千分位 + * - 支持:括号负数 (xxx) 与显式负号 + * - 返回 null 表示无法解析 + */ + public static function toDecimal($v): ?float + { + if ($v === null || $v === '') return null; + if (is_numeric($v)) return (float)$v; + + $s = trim((string)$v); + + // 1) 统一常见 unicode 标点(全角/阿拉伯)与空格 + $s = strtr($s, [ + ',' => ',', '.' => '.', + '٫' => '.', '٬' => ',', + "\xC2\xA0" => ' ', // NBSP + "\xE2\x80\xAF" => ' ', // NNBSP(窄不换行空格) + ]); + + // 2) 括号负数,如 (dh1,234.56) + $negative = false; + if (preg_match('/^\s*\((.*)\)\s*$/u', $s, $m)) { + $negative = true; + $s = $m[1]; + } + + // 3) 去币种/符号:AED/Dirham(dh/dhs/aed/د.إ)& 其它常见符号/字母 + $s = preg_replace('/\b(dhs?|aed)\b/i', '', $s); // dh / dhs / aed + $s = str_replace(['د.إ', 'د', 'إ'], '', $s); // 常见阿语形式简化处理 + $s = preg_replace('/[A-Za-z¥¥$€£₽₺₩₹﷼]+/u', '', $s); // 其它字母/货币符号 + + // 4) 只保留 数字/点/逗号/减号/空格/撇号,然后去掉空格与撇号(千分位) + $s = preg_replace('/[^\d\.,\-\s\']+/u', '', $s); + $s = str_replace([' ', "'"], '', $s); + + // 5) 统一小数分隔符(判断最后出现的分隔符) + $lastComma = strrpos($s, ','); + $lastDot = strrpos($s, '.'); + + if ($lastComma !== false && $lastDot !== false) { + // 同时存在,最后出现者视为小数点 + if ($lastComma > $lastDot) { + // 欧式 1.234,56 + $s = str_replace('.', '', $s); // 去千分点 + $s = substr_replace($s, '.', $lastComma, 1); // 最后一个逗号改为小数点 + $s = str_replace(',', '', $s); // 其余逗号清空 + } else { + // 美式 1,234.56 + $s = str_replace(',', '', $s); + } + } elseif ($lastComma !== false) { + // 只有逗号 + $digitsAfter = strlen($s) - $lastComma - 1; + if (substr_count($s, ',') > 1) { + // 多个逗号基本都是千分位 + $s = str_replace(',', '', $s); + } else { + // 一个逗号:若逗号后 1~2 位,视为小数点,否则千分位 + $s = ($digitsAfter >= 1 && $digitsAfter <= 2) + ? str_replace(',', '.', $s) + : str_replace(',', '', $s); + } + } elseif ($lastDot !== false && substr_count($s, '.') > 1) { + // 多个点:保留最后一个为小数点,其他去掉(当作千分位) + $digitsAfter = strlen($s) - $lastDot - 1; + if ($digitsAfter >= 1 && $digitsAfter <= 2) { + $s = preg_replace('/\.(?=.*\.)/', '', $s); // 移除除最后一个外的点 + } else { + $s = str_replace('.', '', $s); + } + } + + // 6) 处理显式负号 + if (strpos($s, '-') !== false) { + $negative = true; + $s = str_replace('-', '', $s); + } + + // 7) 最终校验并转 float + if ($s === '' || !preg_match('/^\d+(\.\d+)?$/', $s)) { + return null; // 无法解析 + } + + $num = (float)$s; + return $negative ? -$num : $num; + } + + /** 规范化布尔:'是/否'、'Y/N'、'1/0'、'true/false' */ + public static function toBool($v): ?int + { + if ($v === null || $v === '') return null; + $s = mb_strtolower(trim((string)$v)); + if (in_array($s, ['是','y','yes','true','1','√','✅'], true)) return 1; + if (in_array($s, ['否','n','no','false','0','×','❌'], true)) return 0; + return null; + } + + /** 规范化日期(readSheet 已对 Excel 日期做过处理,这里再兜底) */ + public static function toDate($v): ?string + { + if ($v === null || $v === '') return null; + if (is_numeric($v)) { + // Excel 序列号兜底 + $ts = ExcelDate::excelToTimestamp($v); + return $ts ? date('Y-m-d', $ts) : null; + } + // 简单清洗 + $s = trim((string)$v); + // 常见格式直接返回(如 2025-11-04 / 2025/11/04) + if (preg_match('/^\d{4}[-\/]\d{1,2}[-\/]\d{1,2}$/', $s)) { + return str_replace('/', '-', $s); + } + return $s; // 留给上层决定是否再严格校验 + } + + /** + * 把远程 URL 流式下载到临时文件;返回临时路径 + */ + protected static function downloadToTemp(string $url, ?string $ext = null): string + { + $ext = $ext ? ('.' . ltrim($ext, '.')) : ''; + $tmp = sys_get_temp_dir() . '/excel_' . uniqid() . $ext; + $fp = fopen($tmp, 'w'); + if (!$fp) { + throw new \RuntimeException("Cannot open temp file: {$tmp}"); + } + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_FILE => $fp, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_TIMEOUT => 300, + CURLOPT_CONNECTTIMEOUT => 15, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, + CURLOPT_USERAGENT => 'Webman-ExcelReader/1.0', + ]); + $ok = curl_exec($ch); + $http = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $err = curl_error($ch); + curl_close($ch); + fclose($fp); + if ($ok === false || $http >= 400) { + @unlink($tmp); + throw new \RuntimeException("Download failed: HTTP {$http} {$err}"); + } + return $tmp; + } +} diff --git a/app/common/validate/FinanceReconDubaiFilesValidate.php b/app/common/validate/FinanceReconDubaiFilesValidate.php new file mode 100644 index 0000000..193b230 --- /dev/null +++ b/app/common/validate/FinanceReconDubaiFilesValidate.php @@ -0,0 +1,29 @@ + [ + + ], + + 'update' => [ + + ] + ]; +} \ No newline at end of file diff --git a/app/controller/IndexController.php b/app/controller/IndexController.php index 72532c7..dbc493a 100644 --- a/app/controller/IndexController.php +++ b/app/controller/IndexController.php @@ -74,25 +74,25 @@ class IndexController $options = [ 'controller' => [ 'module' => 'common', - 'name' => 'stock_orders_files' + 'name' => 'finance_recon_dubai_files' ], 'model' => [ 'module' => 'common', - 'name' => 'stock_orders_files' + 'name' => 'finance_recon_dubai_files' ], 'service' => [ 'module' => 'common', - 'name' => 'stock_orders_files' + 'name' => 'finance_recon_dubai_files' ], 'logic' => [ 'module' => 'common', - 'name' => 'stock_orders_files' + 'name' => 'finance_recon_dubai_files' ], 'validate' => [ 'module' => 'common', - 'name' => 'stock_orders_files' + 'name' => 'finance_recon_dubai_files' ], - 'table' => 'stock_orders_files' + 'table' => 'finance_recon_dubai_files' ]; $result = (new Generate($options))->doGenerate(); @@ -114,7 +114,7 @@ class IndexController } - public function test(Request $request) + public function pdf(Request $request) { $id = $request->get('id'); $service = new \app\common\service\StockOrdersService(); @@ -154,4 +154,16 @@ class IndexController $data = array('header' => $header,'body' => $response); return $data; } + + + + public function test(Request $request) + { + $tmp = (new \app\common\service\OssService)->generatePrivateUrl('fdesk_test/20251104/20251104_财务.xlsx');; + + $service = new \app\common\service\FinanceRecoDubaiFilesService(); + $result = $service->import($tmp, true); + + return json(['code' => 0, 'msg' => 'ok', 'data' => $result]); + } } \ No newline at end of file diff --git a/config/oss.php b/config/oss.php new file mode 100644 index 0000000..bb03fc7 --- /dev/null +++ b/config/oss.php @@ -0,0 +1,10 @@ + env('OSS_ACCESSID'), + 'oss_accesssecret' => env('OSS_ACCESSSECRET'), + 'oss_bucket' => env('OSS_BUCKET'), + 'oss_endpoint' => env('OSS_ENDPOINT'), + 'oss_static_url' => env('OSS_STATIC_URL'), + 'oss_main_dir' => env('OSS_MAIN_DIR') +]; \ No newline at end of file diff --git a/config/route.php b/config/route.php index 36492d9..c49f9dc 100644 --- a/config/route.php +++ b/config/route.php @@ -472,6 +472,16 @@ Route::group('/admin', function () { Route::any('/claim', [app\admin\controller\dashboard\OfficeClerkController::class, 'claim']); }); }); + + Route::group('/finance', function () { + Route::group('/dubai', function () { + Route::any('/list', [app\admin\controller\FinanceReconDubaiFilesController::class, 'index']); + Route::any('/show', [app\admin\controller\FinanceReconDubaiFilesController::class, 'show']); + Route::any('/store', [app\admin\controller\FinanceReconDubaiFilesController::class, 'store']); + Route::any('/update', [app\admin\controller\FinanceReconDubaiFilesController::class, 'update']); + Route::any('/destroy', [app\admin\controller\FinanceReconDubaiFilesController::class, 'destroy']); + }); + }); }); From 39820844f7605f604ff2b307a5a25b59971daf19 Mon Sep 17 00:00:00 2001 From: mzl Date: Fri, 7 Nov 2025 16:34:57 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E5=BF=BD=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 +- webman | 142 ++++++++++++++++++++++++++--------------------------- 2 files changed, 75 insertions(+), 72 deletions(-) mode change 100644 => 100755 webman diff --git a/.gitignore b/.gitignore index fa572a1..c08e119 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,7 @@ /tests/tmp /tests/.phpunit.result.cache /public/uploads -/webman \ No newline at end of file +/webman +/start.php +/windows.php +/bootstrap.php \ No newline at end of file diff --git a/webman b/webman old mode 100644 new mode 100755 index c853676..3f55062 --- a/webman +++ b/webman @@ -1,71 +1,71 @@ -#!/usr/bin/env php -load(); - } else { - Dotenv::createMutable(run_path())->load(); - } -} - -$appConfig = require $appConfigFile; -if ($timezone = $appConfig['default_timezone'] ?? '') { - date_default_timezone_set($timezone); -} - -if ($errorReporting = $appConfig['error_reporting'] ?? '') { - error_reporting($errorReporting); -} - -if (!in_array($argv[1] ?? '', ['start', 'restart', 'stop', 'status', 'reload', 'connections'])) { - require_once __DIR__ . '/support/bootstrap.php'; -} else { - if (class_exists('Support\App')) { - Support\App::loadAllConfig(['route']); - } else { - Config::reload(config_path(), ['route', 'container']); - } -} - -$cli = new Command(); -$cli->setName('webman cli'); -$cli->installInternalCommands(); -if (is_dir($command_path = Util::guessPath(app_path(), '/command', true))) { - $cli->installCommands($command_path); -} - -foreach (config('plugin', []) as $firm => $projects) { - if (isset($projects['app'])) { - foreach (['', '/app'] as $app) { - if ($command_str = Util::guessPath(base_path() . "/plugin/$firm{$app}", 'command')) { - $command_path = base_path() . "/plugin/$firm{$app}/$command_str"; - $cli->installCommands($command_path, "plugin\\$firm" . str_replace('/', '\\', $app) . "\\$command_str"); - } - } - } - foreach ($projects as $name => $project) { - if (!is_array($project)) { - continue; - } - $project['command'] ??= []; - array_walk($project['command'], [$cli, 'createCommandInstance']); - } -} - -$cli->run(); +#!/usr/bin/env php +load(); + } else { + Dotenv::createMutable(run_path())->load(); + } +} + +$appConfig = require $appConfigFile; +if ($timezone = $appConfig['default_timezone'] ?? '') { + date_default_timezone_set($timezone); +} + +if ($errorReporting = $appConfig['error_reporting'] ?? '') { + error_reporting($errorReporting); +} + +if (!in_array($argv[1] ?? '', ['start', 'restart', 'stop', 'status', 'reload', 'connections'])) { + require_once __DIR__ . '/support/bootstrap.php'; +} else { + if (class_exists('Support\App')) { + Support\App::loadAllConfig(['route']); + } else { + Config::reload(config_path(), ['route', 'container']); + } +} + +$cli = new Command(); +$cli->setName('webman cli'); +$cli->installInternalCommands(); +if (is_dir($command_path = Util::guessPath(app_path(), '/command', true))) { + $cli->installCommands($command_path); +} + +foreach (config('plugin', []) as $firm => $projects) { + if (isset($projects['app'])) { + foreach (['', '/app'] as $app) { + if ($command_str = Util::guessPath(base_path() . "/plugin/$firm{$app}", 'command')) { + $command_path = base_path() . "/plugin/$firm{$app}/$command_str"; + $cli->installCommands($command_path, "plugin\\$firm" . str_replace('/', '\\', $app) . "\\$command_str"); + } + } + } + foreach ($projects as $name => $project) { + if (!is_array($project)) { + continue; + } + $project['command'] ??= []; + array_walk($project['command'], [$cli, 'createCommandInstance']); + } +} + +$cli->run(); From d515bc07fede8a201f4328117d7fa20d74253614 Mon Sep 17 00:00:00 2001 From: mzl Date: Tue, 11 Nov 2025 08:38:32 +0000 Subject: [PATCH 3/3] =?UTF-8?q?=E5=A4=87=E8=B4=A7=E5=8D=95=E8=B0=83?= =?UTF-8?q?=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/common/service/StockOrdersService.php | 40 ++++++++++--------- app/common/utils/pdf/template/stockOrder.stub | 22 ++++++---- app/controller/IndexController.php | 4 +- 3 files changed, 38 insertions(+), 28 deletions(-) diff --git a/app/common/service/StockOrdersService.php b/app/common/service/StockOrdersService.php index 6da7a24..7ee30a4 100644 --- a/app/common/service/StockOrdersService.php +++ b/app/common/service/StockOrdersService.php @@ -655,7 +655,7 @@ class StockOrdersService */ private function generateVehicleUniqueId(array $veh): string { - $keysToRemove = ['vin', 'remark', 'modify_list', 'order_id']; + $keysToRemove = ['remark', 'modify_list', 'order_id']; $array = array_diff_key($veh, array_flip($keysToRemove)); $uniqueIdStr = json_encode($array); return md5($uniqueIdStr); @@ -884,6 +884,7 @@ class StockOrdersService 'num' => 1, 'mileage' => $v->mileage, 'remark' => $v->remark, + 'vin' => $v->vin, ] + $moneyValues; } return ['list' => $vehList]; @@ -925,25 +926,26 @@ class StockOrdersService { $vehicleData['num'] += 1; - $fieldList = [ - 'fob_transport_fee', - 'car_price', - 'ocean_freight', - 'commission', - 'retention_price', - 'port_misc_fee', - 'total_price', - 'clearance_price', - 'profit' - ]; + // ** 11.11 remove 价格*数量 + // $fieldList = [ + // 'fob_transport_fee', + // 'car_price', + // 'ocean_freight', + // 'commission', + // 'retention_price', + // 'port_misc_fee', + // 'total_price', + // 'clearance_price', + // 'profit' + // ]; - foreach ($vehicleData as $field => $value) { - if (in_array($field, $fieldList)) { - $amount = $vehicleData[$field]['amount'] * $vehicleData['num']; - $currencySymbol = SysCurrency::where('currency_code', $vehicleData[$field]['currency'])->value('currency_symbol'); - $vehicleData[$field]['currency_info'] = $currencySymbol . number_format($amount, 2, '.', ','); - } - } + // foreach ($vehicleData as $field => $value) { + // if (in_array($field, $fieldList)) { + // $amount = $vehicleData[$field]['amount'] * $vehicleData['num']; + // $currencySymbol = SysCurrency::where('currency_code', $vehicleData[$field]['currency'])->value('currency_symbol'); + // $vehicleData[$field]['currency_info'] = $currencySymbol . number_format($amount, 2, '.', ','); + // } + // } } /** diff --git a/app/common/utils/pdf/template/stockOrder.stub b/app/common/utils/pdf/template/stockOrder.stub index 697b8eb..807c3e3 100644 --- a/app/common/utils/pdf/template/stockOrder.stub +++ b/app/common/utils/pdf/template/stockOrder.stub @@ -89,7 +89,7 @@
-

合塑实业-汽车事业部-备货单(客户下定)

+

合塑实业-汽车事业部-备货单

备货单号: 业务员: @@ -126,7 +126,7 @@ 车型 数量 颜色 - 费用明细 + 费用明细 @@ -142,9 +142,12 @@ - +

0): ?> - (km) + 里程:km +
+ + VIN: @@ -160,6 +163,7 @@ + 手续费 改装费 配件费 收款价 @@ -171,6 +175,7 @@ + @@ -191,6 +196,7 @@ + 加价 报关价 FOB运费 车价 @@ -202,6 +208,7 @@ + @@ -214,7 +221,7 @@ 备注 - + @@ -231,7 +238,7 @@ 合同汇率: - + 合计到岸价: @@ -261,7 +268,8 @@ 出运方式: 国内收货港: - 贸易条款/清关目的港: / + 贸易条款: + 清关目的港: 提车日期: diff --git a/app/controller/IndexController.php b/app/controller/IndexController.php index dbc493a..c3c75ae 100644 --- a/app/controller/IndexController.php +++ b/app/controller/IndexController.php @@ -15,9 +15,9 @@ use GuzzleHttp\Client; class IndexController { - protected $noNeedLogin = ['index', 'view', 'perm', 'createResource', 'test']; + protected $noNeedLogin = ['index', 'view', 'perm', 'createResource', 'pdf', 'test']; - protected $noNeedHeaderData = ['index', 'view', 'perm', 'createResource', 'test']; + protected $noNeedHeaderData = ['index', 'view', 'perm', 'createResource', 'pdf', 'test']; public function index(Request $request) {