, * balances:array, * reserveStandards:array,withdrawalAmounts:array,compliance:array, * balanceColumnLabel:string,balanceDate:string * } $report */ public function export(array $report): StreamedResponse { $spreadsheet = $this->workbook($report); return $this->download( $spreadsheet, 'withdrawal_management_' . str_replace('-', '', $report['balanceDate']) . '_' . now()->format('His') . '.xlsx', ); } public function template(): StreamedResponse { $spreadsheet = new Spreadsheet(); $sheet = $spreadsheet->getActiveSheet(); $sheet->setTitle('提现管理'); $this->writeHeader($sheet, [...self::CONFIG_HEADERS, '提现金额']); $this->sizeColumns($sheet, [14, 30, 20, 32, 16, 24, 22, 18]); $instructions = $spreadsheet->createSheet(); $instructions->setTitle('填写说明'); $instructions->fromArray([ ['提现管理导入说明'], ['1. 店铺平台、ERP店铺名称用于定位店铺;有平台店铺ID时优先按平台和ID匹配。'], ['2. 店铺主体、提现路径、提现账户名称、银行卡号后四位和提现金额为可更新字段。'], ['3. 可更新字段留空表示保持原值,填写“' . self::CLEAR_VALUE . '”表示清空原值。'], ['4. 提现金额须为非负数,最多两位小数,只用于导入当天对应的余额数据日期;上传后先预览再确认。'], ], null, 'A1'); $instructions->getStyle('A1')->getFont()->setBold(true)->setSize(14); $instructions->getColumnDimension('A')->setWidth(88); $instructions->getStyle('A1:A5')->getAlignment()->setWrapText(true)->setVertical(Alignment::VERTICAL_TOP); foreach (range(1, 5) as $row) { $instructions->getRowDimension($row)->setRowHeight($row === 1 ? 26 : 34); } $spreadsheet->setActiveSheetIndex(0); return $this->download($spreadsheet, 'withdrawal_management_import_template.xlsx'); } /** * @return array{ * total:int,changed:int,unchanged:int, * preview:list, * plan:list}> * } */ public function preview(UploadedFile $file, int $companyId): array { try { $spreadsheet = IOFactory::load($file->getRealPath()); $sheet = $spreadsheet->getSheetByName('提现管理') ?? $spreadsheet->getActiveSheet(); $matrix = $sheet->toArray(null, false, true, false); $spreadsheet->disconnectWorksheets(); } catch (Throwable) { throw ValidationException::withMessages([ 'import' => 'Excel 文件无法读取,请确认文件未损坏并使用最新模板。', ]); } [$headerIndex, $headers] = $this->headers($matrix); $indexes = array_flip($headers); $balanceDate = now()->subDay()->toDateString(); $accounts = StoreReceivingAccount::withoutGlobalScopes() ->where('company_id', $companyId) ->whereNull('deleted_at') ->get(); [$byId, $byName] = $this->indexes($accounts); $errors = []; $plan = []; $preview = []; $seen = []; $total = 0; $unchanged = 0; foreach (array_slice($matrix, $headerIndex + 1, null, true) as $matrixIndex => $row) { $values = []; foreach (self::CONFIG_HEADERS as $header) { $values[$header] = trim((string) ($row[$indexes[$header]] ?? '')); } $values['提现金额'] = isset($indexes['提现金额']) ? trim((string) ($row[$indexes['提现金额']] ?? '')) : ''; if (count(array_filter($values, static fn (string $value): bool => $value !== '')) === 0) { continue; } $total++; if ($total > 5000) { throw ValidationException::withMessages(['import' => '单次最多导入 5000 行,请拆分文件后重试。']); } $line = $matrixIndex + 1; $platform = mb_substr($values['店铺平台'], 0, 80); $erpStoreName = mb_substr($values['ERP店铺名称'], 0, 191); $storeId = mb_substr(ltrim($values['平台店铺ID'], "'"), 0, 191); if ($platform === '' || $erpStoreName === '') { $errors[] = "第 {$line} 行缺少店铺平台或 ERP 店铺名称"; continue; } $matches = $storeId !== '' ? ($byId[$this->key($platform, $storeId)] ?? []) : ($byName[$this->key($platform, $erpStoreName)] ?? []); if (count($matches) === 0) { $identity = $storeId !== '' ? "平台店铺ID {$storeId}" : "ERP 店铺名称 {$erpStoreName}"; $errors[] = "第 {$line} 行未找到 {$platform} / {$identity}"; continue; } if (count($matches) > 1) { $errors[] = "第 {$line} 行匹配到多家店铺,请补充或核对平台店铺ID"; continue; } /** @var StoreReceivingAccount $account */ $account = $matches[0]; if (isset($seen[(int) $account->id])) { $errors[] = "第 {$line} 行与第 {$seen[(int) $account->id]} 行重复指向同一家店铺"; continue; } $seen[(int) $account->id] = $line; try { $changes = $this->changes($account, $values, $balanceDate); } catch (\InvalidArgumentException $exception) { $errors[] = "第 {$line} 行" . $exception->getMessage(); continue; } if ($changes === []) { $unchanged++; continue; } $labels = [ 'store_entity_name' => '店铺主体', 'receiving_method' => '提现路径', 'receiving_account_name' => '提现账户名称', 'bank_card_last_four' => '银行卡号后四位', 'withdrawal_amount' => '提现金额', 'withdrawal_balance_date' => '提现金额', ]; $changedLabels = array_unique(array_map( static fn (string $field): string => $labels[$field], array_keys($changes), )); $plan[] = [ 'id' => (int) $account->id, 'updated_at' => (string) $account->getRawOriginal('updated_at'), 'changes' => $changes, ]; if (count($preview) < 20) { $preview[] = [ 'platform' => $platform, 'erp_store_name' => $erpStoreName, 'store_id' => $storeId, 'changes' => implode('、', $changedLabels), ]; } } if ($total === 0) { throw ValidationException::withMessages(['import' => '导入文件中没有可识别的数据行。']); } if ($errors !== []) { $visible = array_slice($errors, 0, 10); if (count($errors) > count($visible)) { $visible[] = '另有 ' . (count($errors) - count($visible)) . ' 项错误'; } throw ValidationException::withMessages(['import' => implode(';', $visible) . '。']); } return [ 'total' => $total, 'changed' => count($plan), 'unchanged' => $unchanged, 'preview' => $preview, 'plan' => $plan, ]; } /** @param list}> $plan */ public function commit(array $plan, int $companyId): int { return DB::transaction(function () use ($plan, $companyId): int { $updated = 0; foreach ($plan as $item) { /** @var StoreReceivingAccount|null $account */ $account = StoreReceivingAccount::withoutGlobalScopes() ->where('company_id', $companyId) ->whereNull('deleted_at') ->whereKey((int) $item['id']) ->lockForUpdate() ->first(); if ($account === null || (string) $account->getRawOriginal('updated_at') !== (string) $item['updated_at']) { throw ValidationException::withMessages([ 'import' => '预览后店铺配置发生变化,请重新上传并预览。', ]); } if (isset($item['changes']['withdrawal_balance_date']) && $item['changes']['withdrawal_balance_date'] !== null && $item['changes']['withdrawal_balance_date'] !== now()->subDay()->toDateString()) { throw ValidationException::withMessages([ 'import' => '余额日期已滚动,请重新上传并预览提现金额。', ]); } foreach ($item['changes'] as $field => $value) { if ($field === 'bank_card_last_four') { $account->bank_card_last_four = $value; } else { $account->setAttribute($field, $value); } } $account->save(); $updated++; } return $updated; }); } private function workbook(array $report): Spreadsheet { $headers = [...self::CONFIG_HEADERS, $report['balanceColumnLabel'], '预留标准', '提现金额', '是否达标']; $spreadsheet = new Spreadsheet(); $sheet = $spreadsheet->getActiveSheet(); $sheet->setTitle('提现管理'); $this->writeHeader($sheet, $headers); $this->sizeColumns($sheet, [14, 32, 20, 32, 16, 24, 22, 20, 18, 18, 14]); $rowNumber = 2; foreach ($report['rows'] as $row) { $match = $report['balances'][(int) $row->id] ?? ['status' => 'not_ready', 'balance' => null]; $reserve = $report['reserveStandards'][(int) $row->id] ?? null; $withdrawalAmount = $report['withdrawalAmounts'][(int) $row->id] ?? null; $compliance = $report['compliance'][(int) $row->id] ?? null; $textValues = [ 1 => (string) ($row->store_platform ?? ''), 2 => (string) ($row->erp_store_name ?? ''), 3 => (string) ($row->store_id ?? ''), 4 => (string) ($row->store_entity_name ?? ''), 5 => (string) ($row->withdrawal_path ?? ''), 6 => (string) ($row->withdrawal_account_name ?? ''), 7 => (string) ($row->bank_card_last_four ?? ''), ]; foreach ($textValues as $column => $value) { $sheet->setCellValueExplicit([$column, $rowNumber], $value, DataType::TYPE_STRING); } if ($match['status'] === 'matched') { $sheet->setCellValue([8, $rowNumber], (float) $match['balance']); } else { $sheet->setCellValueExplicit([8, $rowNumber], $this->balanceStatus($match['status']), DataType::TYPE_STRING); } if ($reserve === null) { $sheet->setCellValueExplicit([9, $rowNumber], '待配置', DataType::TYPE_STRING); } else { $sheet->setCellValue([9, $rowNumber], $reserve); } if ($withdrawalAmount === null) { $sheet->setCellValueExplicit([10, $rowNumber], '待接入', DataType::TYPE_STRING); } else { $sheet->setCellValue([10, $rowNumber], $withdrawalAmount); } $sheet->setCellValueExplicit([11, $rowNumber], match ($compliance) { 'yes' => '是', 'no' => '否', default => '待核验', }, DataType::TYPE_STRING); $rowNumber++; } if ($rowNumber > 2) { $sheet->getStyle('H2:J' . ($rowNumber - 1))->getNumberFormat()->setFormatCode('#,##0.00'); $sheet->getStyle('H2:J' . ($rowNumber - 1))->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT); } $sheet->getStyle('C2:C' . max(2, $rowNumber - 1))->getNumberFormat()->setFormatCode('@'); $sheet->getStyle('G2:G' . max(2, $rowNumber - 1))->getNumberFormat()->setFormatCode('@'); $spreadsheet->getProperties()->setTitle('提现管理')->setDescription('余额数据日期:' . $report['balanceDate']); return $spreadsheet; } /** @return array{0:int,1:list} */ private function headers(array $matrix): array { foreach (array_slice($matrix, 0, 10, true) as $index => $row) { $headers = array_map(static fn (mixed $value): string => trim((string) $value), $row); if (in_array('店铺平台', $headers, true) && in_array('ERP店铺名称', $headers, true)) { foreach (self::CONFIG_HEADERS as $required) { if (count(array_keys($headers, $required, true)) !== 1) { throw ValidationException::withMessages([ 'import' => "导入表头必须且只能包含一个“{$required}”列,请下载最新模板。", ]); } } if (count(array_keys($headers, '提现金额', true)) > 1) { throw ValidationException::withMessages(['import' => '导入表头只能包含一个“提现金额”列。']); } return [(int) $index, $headers]; } } throw ValidationException::withMessages([ 'import' => '未找到提现管理导入表头,请下载最新模板。', ]); } /** @return array{0:array>,1:array>} */ private function indexes($accounts): array { $byId = []; $byName = []; foreach ($accounts as $account) { $platform = trim((string) $account->store_platform); $storeId = trim((string) ($account->store_id ?: $account->wallet_number)); $erpStoreName = trim((string) $account->erp_store_name); if ($platform !== '' && $storeId !== '') { $byId[$this->key($platform, $storeId)][] = $account; } if ($platform !== '' && $erpStoreName !== '') { $byName[$this->key($platform, $erpStoreName)][] = $account; } } return [$byId, $byName]; } /** @param array $values @return array */ private function changes(StoreReceivingAccount $account, array $values, string $balanceDate): array { $fields = [ '店铺主体' => ['field' => 'store_entity_name', 'current' => $account->store_entity_name, 'max' => 191, 'clear' => ''], '提现路径' => ['field' => 'receiving_method', 'current' => $account->withdrawal_path, 'max' => 191, 'clear' => ''], '提现账户名称' => ['field' => 'receiving_account_name', 'current' => $account->withdrawal_account_name, 'max' => 191, 'clear' => null], '银行卡号(后四位)' => ['field' => 'bank_card_last_four', 'current' => $account->bank_card_last_four, 'max' => 4, 'clear' => null], ]; $changes = []; foreach ($fields as $header => $definition) { $raw = trim((string) ($values[$header] ?? '')); if ($raw === '') { continue; } $value = $raw === self::CLEAR_VALUE ? $definition['clear'] : $raw; if ($header === '银行卡号(后四位)' && $value !== null) { if (preg_match('/^\d{4}$/', $value) !== 1) { throw new \InvalidArgumentException('的银行卡号必须填写后四位数字'); } } if ($value !== null && mb_strlen($value) > $definition['max']) { throw new \InvalidArgumentException("的{$header}超过 {$definition['max']} 个字符"); } $current = trim((string) ($definition['current'] ?? '')) ?: null; $comparisonValue = $value === '' ? null : $value; if ($current !== $comparisonValue) { $changes[$definition['field']] = $value; } } $amount = $values['提现金额'] ?? ''; if ($amount !== '' && $amount !== '待接入') { if ($amount === self::CLEAR_VALUE) { if ($account->withdrawal_amount !== null || $account->withdrawal_balance_date !== null) { $changes['withdrawal_amount'] = null; $changes['withdrawal_balance_date'] = null; } } else { if (preg_match('/^\d{1,10}(?:\.\d{1,2})?$/', $amount) !== 1) { throw new \InvalidArgumentException('的提现金额须为非负数,最多两位小数'); } $normalized = number_format((float) $amount, 2, '.', ''); if ($account->withdrawal_amount !== $normalized || $account->withdrawal_balance_date !== $balanceDate) { $changes['withdrawal_amount'] = $normalized; $changes['withdrawal_balance_date'] = $balanceDate; } } } return $changes; } private function key(string $platform, string $identity): string { return trim($platform) . "\0" . trim($identity); } private function balanceStatus(string $status): string { return match ($status) { 'ambiguous' => '多条匹配', 'unmatched' => '未匹配', default => '待同步', }; } private function writeHeader($sheet, array $headers): void { $lastColumn = Coordinate::stringFromColumnIndex(count($headers)); $sheet->fromArray($headers, null, 'A1'); $sheet->freezePane('A2'); $sheet->setAutoFilter("A1:{$lastColumn}1"); $sheet->getRowDimension(1)->setRowHeight(26); $sheet->getStyle("A1:{$lastColumn}1")->getFont()->setBold(true)->getColor()->setARGB('FFFFFFFF'); $sheet->getStyle("A1:{$lastColumn}1")->getFill()->setFillType(Fill::FILL_SOLID)->getStartColor()->setARGB('FF856632'); $sheet->getStyle("A1:{$lastColumn}1")->getAlignment()->setVertical(Alignment::VERTICAL_CENTER); } private function sizeColumns($sheet, array $widths): void { foreach ($widths as $index => $width) { $sheet->getColumnDimension(Coordinate::stringFromColumnIndex($index + 1))->setWidth($width); } } private function download(Spreadsheet $spreadsheet, string $filename): StreamedResponse { return response()->streamDownload(function () use ($spreadsheet): void { (new Xlsx($spreadsheet))->save('php://output'); $spreadsheet->disconnectWorksheets(); }, $filename, [ 'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'Cache-Control' => 'private, no-store', ]); } }