2026-09-09 14:08:58 +08:00

380 lines
14 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace Modules\EcommerceFinance\Http\Controllers;
use App\Utilities\SidebarPermissions;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Modules\EcommerceFinance\AccountBillColumns;
use Modules\EcommerceFinance\Models\AccountBill;
use Modules\EcommerceFinance\Repositories\StoreReceivingDouyinBillMirrorRepository;
use RuntimeException;
class AccountBills extends Controller
{
/**
* 通用账单列表(店铺收款账户账单 › 各账户类型 tab)。
* 经 admin 路由组(web 会话)暴露,前端带 CSRF 调用。
* 按 bill_type 过滤,返回分页数据 + 汇总 + 该类型的列配置。
*/
public function index(Request $request, string $bill_type)
{
$this->authorizeStoreReceivingBills();
$billType = $bill_type;
if (! AccountBillColumns::has($billType)) {
abort(404, '未知的账户类型。');
}
if ($billType === 'douyin') {
return $request->boolean('export')
? $this->export($request, $billType)
: $this->douyinIndex($request);
}
$request->validate([
'store_name' => ['nullable', 'string', 'max:255'],
'bill_item' => ['nullable', 'string', 'max:255'],
'start' => ['nullable', 'date_format:Y-m-d'],
'end' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start'],
'keyword' => ['nullable', 'string', 'max:255'],
'export' => ['nullable', 'boolean'],
'columns' => ['nullable', 'array', 'max:40'],
'columns.*' => ['string', 'max:128'],
]);
$query = AccountBill::query()
->where('company_id', company_id())
->where('bill_type', $billType);
if ($store = $request->get('store_name')) {
$query->where('store_name', $store);
}
if ($item = $request->get('bill_item')) {
$query->where('bill_item', $item);
}
if ($start = $request->get('start')) {
$query->where('occurred_at', '>=', $start);
}
if ($end = $request->get('end')) {
$query->where('occurred_at', '<=', $end . ' 23:59:59');
}
if ($keyword = $request->get('keyword')) {
$query->where(function ($q) use ($keyword) {
$like = '%' . $keyword . '%';
$q->where('record_code', 'like', $like)
->orWhere('order_no', 'like', $like)
->orWhere('remark', 'like', $like)
->orWhere('bill_item', 'like', $like);
});
}
$sortable = ['occurred_at', 'income', 'expense', 'balance', 'record_code'];
$sort = in_array($request->get('sort'), $sortable, true) ? $request->get('sort') : 'occurred_at';
$dir = $request->get('dir') === 'asc' ? 'asc' : 'desc';
$query->orderBy($sort, $dir);
if ($request->boolean('export')) {
return $this->exportQuery($request, $query, $billType);
}
$perPage = min(max((int) $request->get('limit', 10), 1), 100);
$rows = $query->paginate($perPage);
// 汇总(分页前)
$summaryRow = (clone $query)->selectRaw(
'COALESCE(SUM(income), 0) as income, ' .
'COALESCE(SUM(expense), 0) as expense, ' .
'COUNT(*) as count'
)->first();
$latestBalance = (clone $query)
->orderByDesc('occurred_at')
->orderByDesc('id')
->value('balance');
$data = $rows->toArray();
$data['summary'] = [
'income' => (float) ($summaryRow->income ?? 0),
'expense' => (float) ($summaryRow->expense ?? 0),
'count' => (int) ($summaryRow->count ?? 0),
'balance' => $latestBalance !== null ? (float) $latestBalance : 0,
];
$data['columns'] = AccountBillColumns::for($billType);
return response()->json($data);
}
public function export(Request $request, string $bill_type)
{
$this->authorizeStoreReceivingBills();
abort_unless(AccountBillColumns::has($bill_type), 404, '未知的账户类型。');
if ($bill_type === 'douyin') {
return $this->exportDouyin($request);
}
$filters = $request->validate([
'store_name' => ['nullable', 'string', 'max:255'],
'bill_item' => ['nullable', 'string', 'max:255'],
'start' => ['nullable', 'date_format:Y-m-d'],
'end' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start'],
'keyword' => ['nullable', 'string', 'max:255'],
]);
$query = $this->filteredQuery($request, $bill_type);
$sortable = ['occurred_at', 'income', 'expense', 'balance', 'record_code'];
$sort = in_array($request->get('sort'), $sortable, true) ? $request->get('sort') : 'occurred_at';
$dir = $request->get('dir') === 'asc' ? 'asc' : 'desc';
$query->orderBy($sort, $dir);
return $this->exportQuery($request, $query, $bill_type);
}
private function exportQuery(Request $request, $query, string $billType)
{
$definition = AccountBillColumns::for($billType);
$available = collect($definition['columns'] ?? [])->keyBy('field');
$requested = $request->input('columns', []);
if (is_string($requested)) {
$requested = array_filter(array_map('trim', explode(',', $requested)));
}
if (! is_array($requested)) {
$requested = [];
}
$columns = $requested === []
? $available->values()
: collect($requested)->unique()->map(fn (string $field) => $available->get($field))->filter()->values();
abort_if($columns->isEmpty(), 422, '至少选择一个可导出的字段。');
return response()->streamDownload(function () use ($query, $columns): void {
$csv = fopen('php://output', 'w');
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, $columns->pluck('label')->all());
foreach ($query->cursor() as $row) {
fputcsv($csv, $columns->map(function (array $column) use ($row): string {
$value = $this->columnValue($row, $column['field']);
return $this->csvValue($value, $column['kind']);
})->all());
}
fclose($csv);
}, $billType . '_account_bills.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
private function columnValue(AccountBill $row, string $field): mixed
{
if (str_starts_with($field, 'payload.')) {
return data_get($row->payload ?? [], substr($field, 8), '');
}
return $row->getAttribute($field) ?? '';
}
private function csvValue(mixed $value, string $kind): string
{
$text = (string) ($value ?? '');
if ($kind === 'num' && preg_match('/^\d{12,}$/', $text)) {
return "\t" . $text;
}
return preg_match('/^[=+\-@]/', $text) ? "'" . $text : $text;
}
private function filteredQuery(Request $request, string $billType)
{
$query = AccountBill::query()
->where('company_id', company_id())
->where('bill_type', $billType);
if ($store = $request->get('store_name')) {
$query->where('store_name', $store);
}
if ($item = $request->get('bill_item')) {
$query->where('bill_item', $item);
}
if ($start = $request->get('start')) {
$query->where('occurred_at', '>=', $start);
}
if ($end = $request->get('end')) {
$query->where('occurred_at', '<=', $end . ' 23:59:59');
}
if ($keyword = $request->get('keyword')) {
$query->where(function ($q) use ($keyword): void {
$like = '%' . $keyword . '%';
$q->where('record_code', 'like', $like)
->orWhere('order_no', 'like', $like)
->orWhere('remark', 'like', $like)
->orWhere('bill_item', 'like', $like);
});
}
return $query;
}
private function douyinIndex(Request $request)
{
$filters = $this->douyinFilters($request);
$sort = $this->sort($request);
$direction = $this->direction($request);
$page = max((int) $request->get('page', 1), 1);
$perPage = min(max((int) $request->get('limit', 10), 1), 100);
try {
$data = app(StoreReceivingDouyinBillMirrorRepository::class)->paginate(
$filters,
$sort,
$direction,
$page,
$perPage,
);
} catch (RuntimeException $exception) {
if ($exception->getMessage() !== 'store_receiving_alipay_bill_mirror_not_ready') {
throw $exception;
}
return response()->json([
'message' => '抖音账户账单共享业务镜像正在首次同步,完成后将自动显示。',
'data' => [],
'total' => 0,
'current_page' => 1,
'last_page' => 1,
'per_page' => $perPage,
'summary' => ['income' => 0, 'expense' => 0, 'balance' => 0, 'count' => 0],
'columns' => AccountBillColumns::for('douyin'),
], 503);
}
$data['columns'] = AccountBillColumns::for('douyin');
return response()->json($data);
}
private function exportDouyin(Request $request)
{
$filters = $this->douyinFilters($request);
$definition = AccountBillColumns::for('douyin');
$columns = collect($definition['columns'] ?? []);
$requested = $request->query('columns', []);
if (is_string($requested)) {
$requested = array_filter(array_map('trim', explode(',', $requested)));
}
if (! is_array($requested)) {
$requested = [];
}
$requested = collect($requested)->map(static fn (string $field): string => trim($field))->filter()->unique()->values();
if ($requested->isEmpty()) {
$selected = $columns;
} else {
$columnsByField = $columns->keyBy('field');
$selected = $requested
->map(static fn (string $field) => $columnsByField->get($field))
->filter(static fn ($column): bool => is_array($column))
->values();
abort_if($selected->isEmpty(), 422, '未选择有效的导出字段。');
}
$sort = $this->sort($request);
$direction = $this->direction($request);
try {
$rows = app(StoreReceivingDouyinBillMirrorRepository::class)
->exportRows($filters, $sort, $direction);
} catch (RuntimeException $exception) {
if ($exception->getMessage() !== 'store_receiving_alipay_bill_mirror_not_ready') {
throw $exception;
}
return response()->json(['message' => '抖音账户账单共享业务镜像正在首次同步,完成后将自动导出。'], 503);
}
$headers = $selected->pluck('label')->all();
return response()->streamDownload(function () use ($rows, $selected, $headers): void {
$csv = fopen('php://output', 'w');
fputs($csv, "\xEF\xBB\xBF");
fputcsv($csv, $headers);
foreach ($rows as $row) {
fputcsv($csv, $selected->map(
fn (array $column): string => $this->exportArrayValue($row, $column)
)->all());
}
fclose($csv);
}, 'douyin_account_bills.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
/** @return array<string,string> */
private function douyinFilters(Request $request): array
{
$filters = $request->validate([
'store_name' => ['nullable', 'string', 'max:255'],
'bill_item' => ['nullable', 'string', 'max:255'],
'start' => ['nullable', 'date_format:Y-m-d'],
'end' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start'],
'keyword' => ['nullable', 'string', 'max:255'],
]);
return array_map(static fn (mixed $value): string => trim((string) $value), $filters);
}
private function sort(Request $request): string
{
return in_array($request->get('sort'), ['occurred_at', 'income', 'expense', 'balance', 'record_code'], true)
? (string) $request->get('sort')
: 'occurred_at';
}
private function direction(Request $request): string
{
return $request->get('dir') === 'asc' ? 'asc' : 'desc';
}
private function exportArrayValue(array $row, array $column): string
{
$field = (string) ($column['field'] ?? '');
$value = str_starts_with($field, 'payload.')
? data_get($row['payload'] ?? [], substr($field, 8))
: data_get($row, $field);
$text = trim((string) ($value ?? ''));
if (preg_match('/^\d{12,}$/', $text) === 1) {
return '="' . $text . '"';
}
return preg_match('/^[=+\-@]/u', $text) === 1 ? "'" . $text : $text;
}
public function show(AccountBill $bill)
{
$this->authorizeStoreReceivingBills();
return response()->json($bill);
}
protected function authorizeStoreReceivingBills(): void
{
$section = collect(SidebarPermissions::navigationFor(user()))
->firstWhere('slug', 'billing-transactions');
$page = collect($section['pages'] ?? [])
->firstWhere('slug', 'store-receiving-bills');
abort_if(empty($page), 403, '当前账号没有店铺收款账户账单权限。');
}
}