1401 lines
66 KiB
PHP
1401 lines
66 KiB
PHP
<?php
|
||
|
||
namespace App\Http\Controllers\Business;
|
||
|
||
use App\Utilities\FinanceLedgerAccountStatus;
|
||
use App\Utilities\FinanceLedgerAccountShouldKeep;
|
||
use App\Utilities\FinanceLedgerAccountType;
|
||
use App\Utilities\FinanceLedgerAccess;
|
||
use Illuminate\Http\JsonResponse;
|
||
use Illuminate\Http\Request;
|
||
use Illuminate\Routing\Controller;
|
||
use Illuminate\Support\Carbon;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Str;
|
||
use Illuminate\Validation\Rule;
|
||
use Illuminate\Validation\ValidationException;
|
||
use Modules\EcommerceFinance\Services\FinanceLedgerAccountTemplate;
|
||
use Modules\EcommerceFinance\Services\FinanceLedgerCalculator;
|
||
use Modules\EcommerceFinance\Services\FinanceLedgerPayableMappingBulk;
|
||
use Modules\EcommerceFinance\Services\FinanceLedgerShopDataSynchronizer;
|
||
use Modules\EcommerceFinance\Services\FinanceLedgerShopMappingBulk;
|
||
use Modules\EcommerceFinance\Services\FinanceLedgerTransactionWriter;
|
||
use Modules\EcommerceFinance\View\FinanceLedgerPageData;
|
||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||
use Throwable;
|
||
|
||
class FinanceLedger extends Controller
|
||
{
|
||
private const UNRETAINED_OWNER_OPTIONS = [
|
||
'玉兔',
|
||
'山时',
|
||
];
|
||
|
||
public function downloadAccountTemplate(FinanceLedgerAccountTemplate $template): StreamedResponse
|
||
{
|
||
return $template->download();
|
||
}
|
||
|
||
public function syncShopData(FinanceLedgerShopDataSynchronizer $synchronizer)
|
||
{
|
||
abort_unless(FinanceLedgerAccess::canEditAccountStatus(user()), 403, '只有财务台账管理员可以同步店铺余额和提现。');
|
||
|
||
$target = route('business.pages.show', [
|
||
'section' => 'financial-operations',
|
||
'page' => 'ledger',
|
||
]) . '?tab=shops';
|
||
|
||
try {
|
||
$result = $synchronizer->sync(now()->subDays(30)->toDateString());
|
||
} catch (Throwable $exception) {
|
||
report($exception);
|
||
|
||
return redirect()->to($target)->with('error', '店铺数据同步失败:' . $exception->getMessage());
|
||
}
|
||
|
||
$message = sprintf(
|
||
'已补充确认店铺 %s 个,同步上一晚店铺余额 %s 个,合计 %s 元;新增或更新提现 %s 笔,收入 %s 元%s。',
|
||
number_format((int) $result['balance']['added']),
|
||
number_format((int) $result['balance']['matched']),
|
||
number_format((float) $result['balance']['amount'], 2),
|
||
number_format((int) $result['withdrawals']['written']),
|
||
number_format((float) $result['withdrawals']['income'], 2),
|
||
$result['withdrawals']['upstream_duplicates'] > 0
|
||
? ',跳过已有记录 ' . number_format((int) $result['withdrawals']['upstream_duplicates']) . ' 笔'
|
||
: '',
|
||
);
|
||
if ($result['withdrawals']['unmatched'] > 0 || $result['withdrawals']['account_missing'] > 0) {
|
||
$message .= sprintf(
|
||
';店铺未匹配 %s 笔,账户缺失 %s 笔',
|
||
number_format((int) $result['withdrawals']['unmatched']),
|
||
number_format((int) $result['withdrawals']['account_missing']),
|
||
);
|
||
}
|
||
|
||
return redirect()->to($target)->with('success', $message);
|
||
}
|
||
|
||
public function unavailableBalanceTasks(Request $request)
|
||
{
|
||
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||
$abnormalStatuses = array_values(array_diff(
|
||
array_keys(FinanceLedgerAccountStatus::OPTIONS),
|
||
[FinanceLedgerAccountStatus::NORMAL],
|
||
));
|
||
$handlingMethods = [
|
||
'online' => '线上',
|
||
'offline' => '线下',
|
||
];
|
||
$completionStatuses = [
|
||
'completed' => '已完成',
|
||
'in_progress' => '进行中',
|
||
'not_started' => '待开始',
|
||
'incomplete' => '未完成',
|
||
];
|
||
$filters = [
|
||
'pending_short' => trim((string) $request->query('pending_short', '')),
|
||
'pending_type' => trim((string) $request->query('pending_type', '')),
|
||
'pending_status' => trim((string) $request->query('pending_status', '')),
|
||
'pending_reason' => trim((string) $request->query('pending_reason', '')),
|
||
'pending_method' => trim((string) $request->query('pending_method', '')),
|
||
'pending_owner' => trim((string) $request->query('pending_owner', '')),
|
||
];
|
||
|
||
$query = $connection->table('fin_accounts as accounts')
|
||
->join('fin_account_statuses as account_status', 'account_status.account_short', '=', 'accounts.account_short')
|
||
->leftJoin('fin_account_type_overrides as type_override', 'type_override.account_short', '=', 'accounts.account_short')
|
||
->leftJoin('fin_account_todos as account_todo', 'account_todo.account_short', '=', 'accounts.account_short')
|
||
->whereIn('account_status.status', $abnormalStatuses);
|
||
|
||
if ($filters['pending_short'] !== '') {
|
||
$query->where('accounts.account_short', 'like', '%' . $filters['pending_short'] . '%');
|
||
}
|
||
if ($filters['pending_type'] !== '') {
|
||
$query->where(function ($effectiveType) use ($filters): void {
|
||
$effectiveType->where(function ($baseType) use ($filters): void {
|
||
$baseType->whereNull('type_override.id');
|
||
if ($filters['pending_type'] === '__empty__') {
|
||
$baseType->where(function ($blank): void {
|
||
$blank->whereNull('accounts.account_type')->orWhere('accounts.account_type', '');
|
||
});
|
||
} else {
|
||
$baseType->where('accounts.account_type', $filters['pending_type']);
|
||
}
|
||
})->orWhere(function ($overrideType) use ($filters): void {
|
||
$overrideType->whereNotNull('type_override.id');
|
||
if ($filters['pending_type'] === '__empty__') {
|
||
$overrideType->where(function ($blank): void {
|
||
$blank->whereNull('type_override.account_type')->orWhere('type_override.account_type', '');
|
||
});
|
||
} else {
|
||
$overrideType->where('type_override.account_type', $filters['pending_type']);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
if ($filters['pending_status'] !== '' && array_key_exists($filters['pending_status'], FinanceLedgerAccountStatus::OPTIONS)) {
|
||
$query->where('account_status.status', $filters['pending_status']);
|
||
}
|
||
if ($filters['pending_reason'] !== '') {
|
||
$query->where('account_todo.abnormal_reason', 'like', '%' . $filters['pending_reason'] . '%');
|
||
}
|
||
if ($filters['pending_method'] !== '' && array_key_exists($filters['pending_method'], $handlingMethods)) {
|
||
$query->where('account_todo.handling_method', $filters['pending_method']);
|
||
}
|
||
if ($filters['pending_owner'] !== '') {
|
||
$query->where('account_todo.owner_name', 'like', '%' . $filters['pending_owner'] . '%');
|
||
}
|
||
|
||
$summaryQuery = clone $query;
|
||
$summary = [
|
||
'count' => (clone $query)->count(),
|
||
'current_balance' => round((float) (clone $summaryQuery)->sum('accounts.current_balance'), 2),
|
||
'balance' => round((float) (clone $summaryQuery)->sum('accounts.daily_available'), 2),
|
||
'blocked' => round((float) (clone $summaryQuery)->sum('accounts.blocked_amount'), 2),
|
||
'daily_available' => round((float) (clone $summaryQuery)->sum('accounts.daily_available'), 2),
|
||
];
|
||
|
||
$sortField = (string) $request->query('pending_sort', '');
|
||
$sortField = in_array($sortField, ['started_at', 'completed_at'], true) ? $sortField : '';
|
||
$sortDirection = strtolower((string) $request->query('pending_direction', 'asc'));
|
||
$sortDirection = $sortDirection === 'desc' ? 'desc' : 'asc';
|
||
|
||
$query->select([
|
||
'accounts.*',
|
||
'account_status.status as effective_account_status',
|
||
'type_override.id as type_override_id',
|
||
'type_override.account_type as effective_account_type',
|
||
'account_todo.abnormal_reason as todo_abnormal_reason',
|
||
'account_todo.handling_method as todo_handling_method',
|
||
'account_todo.owner_name as todo_owner_name',
|
||
'account_todo.started_at as todo_started_at',
|
||
'account_todo.completed_at as todo_completed_at',
|
||
'account_todo.current_progress as todo_current_progress',
|
||
'account_todo.completion_status as todo_completion_status',
|
||
]);
|
||
|
||
if ($sortField !== '') {
|
||
$query->orderBy('account_todo.' . $sortField, $sortDirection);
|
||
} else {
|
||
$query->orderByDesc('accounts.blocked_amount');
|
||
}
|
||
$rows = $query
|
||
->orderBy('accounts.company_name')
|
||
->orderBy('accounts.account_short')
|
||
->paginate(50)
|
||
->appends($filters)
|
||
->appends(array_filter([
|
||
'pending_sort' => $sortField,
|
||
'pending_direction' => $sortField !== '' ? $sortDirection : '',
|
||
]))
|
||
->withQueryString();
|
||
|
||
$rows->getCollection()->transform(static function ($row) {
|
||
$row->account_status = FinanceLedgerAccountStatus::normalize($row->effective_account_status ?? null);
|
||
if ($row->type_override_id !== null) {
|
||
$row->account_type = (string) ($row->effective_account_type ?? '');
|
||
}
|
||
$row->todo_completion_status = $row->todo_completion_status ?: 'not_started';
|
||
|
||
return $row;
|
||
});
|
||
|
||
$typeOptions = FinanceLedgerAccountType::options(
|
||
$connection->table('fin_accounts')
|
||
->whereNotNull('account_type')->where('account_type', '<>', '')
|
||
->distinct()->orderBy('account_type')->pluck('account_type')->all(),
|
||
$connection->table('fin_account_type_overrides')
|
||
->whereNotNull('account_type')->where('account_type', '<>', '')
|
||
->distinct()->orderBy('account_type')->pluck('account_type')->all(),
|
||
);
|
||
$reasonOptions = $connection->table('fin_account_todos')
|
||
->whereNotNull('abnormal_reason')->where('abnormal_reason', '<>', '')
|
||
->distinct()->orderBy('abnormal_reason')->pluck('abnormal_reason')->all();
|
||
$ownerOptions = $connection->table('fin_account_todos')
|
||
->whereNotNull('owner_name')->where('owner_name', '<>', '')
|
||
->distinct()->orderBy('owner_name')->pluck('owner_name')->all();
|
||
|
||
return view('business.pages.financial-ledger.unavailable-balance-tasks', [
|
||
'rows' => $rows,
|
||
'summary' => $summary,
|
||
'filters' => $filters,
|
||
'sortField' => $sortField,
|
||
'sortDirection' => $sortDirection,
|
||
'backUrl' => route('business.pages.show', [
|
||
'section' => 'financial-operations',
|
||
'page' => 'ledger',
|
||
]) . '?tab=accounts',
|
||
'canEdit' => FinanceLedgerAccess::canEditAccountStatus(user()),
|
||
'handlingMethods' => $handlingMethods,
|
||
'completionStatuses' => $completionStatuses,
|
||
'accountStatusOptions' => array_intersect_key(
|
||
FinanceLedgerAccountStatus::OPTIONS,
|
||
array_flip($abnormalStatuses),
|
||
),
|
||
'accountTypeOptions' => $typeOptions,
|
||
'reasonOptions' => $reasonOptions,
|
||
'ownerOptions' => $ownerOptions,
|
||
]);
|
||
}
|
||
|
||
public function unretainedAccountTasks(Request $request)
|
||
{
|
||
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||
$handlingMethods = [
|
||
'online' => '线上',
|
||
'offline' => '线下',
|
||
];
|
||
$completionStatuses = [
|
||
'completed' => '已完成',
|
||
'in_progress' => '进行中',
|
||
'not_started' => '待开始',
|
||
];
|
||
$filters = [
|
||
'keep_short' => trim((string) $request->query('keep_short', '')),
|
||
'keep_type' => trim((string) $request->query('keep_type', '')),
|
||
'keep_status' => trim((string) $request->query('keep_status', '')),
|
||
'keep_method' => trim((string) $request->query('keep_method', '')),
|
||
'keep_owner' => trim((string) $request->query('keep_owner', '')),
|
||
'keep_progress' => trim((string) $request->query('keep_progress', '')),
|
||
];
|
||
$headerFilterValue = trim((string) $request->query('keep_filter_value', ''));
|
||
$headerFilterColumn = trim((string) $request->query('keep_filter_column', ''));
|
||
$headerFilterColumns = [
|
||
'company_name',
|
||
'account_short',
|
||
'legal_representative',
|
||
'account_type',
|
||
'account_status',
|
||
'account_no',
|
||
'bank_name',
|
||
'bank_city_branch',
|
||
'account_should_keep',
|
||
'handling_method',
|
||
'owner_name',
|
||
'started_at',
|
||
'completed_at',
|
||
'current_progress',
|
||
];
|
||
if ($headerFilterValue === '' || ! in_array($headerFilterColumn, $headerFilterColumns, true)) {
|
||
$headerFilterColumn = '';
|
||
$headerFilterValue = '';
|
||
}
|
||
|
||
$query = $connection->table('fin_accounts as accounts')
|
||
->leftJoin('fin_account_statuses as account_status', 'account_status.account_short', '=', 'accounts.account_short')
|
||
->leftJoin('fin_account_type_overrides as type_override', 'type_override.account_short', '=', 'accounts.account_short')
|
||
->leftJoin('fin_account_keep_todos as account_todo', 'account_todo.account_short', '=', 'accounts.account_short')
|
||
->select([
|
||
'accounts.*',
|
||
'accounts.account_type as sort_account_type',
|
||
'account_status.status as effective_account_status',
|
||
'type_override.id as type_override_id',
|
||
'type_override.account_type as effective_account_type',
|
||
'account_todo.handling_method as todo_handling_method',
|
||
'account_todo.owner_name as todo_owner_name',
|
||
'account_todo.started_at as todo_started_at',
|
||
'account_todo.completed_at as todo_completed_at',
|
||
'account_todo.current_progress as todo_current_progress',
|
||
]);
|
||
FinanceLedgerAccountShouldKeep::applyUnretainedCondition(
|
||
$query,
|
||
'accounts.account_should_keep',
|
||
'accounts.company_plan',
|
||
);
|
||
|
||
if ($filters['keep_short'] !== '') {
|
||
$query->where('accounts.account_short', 'like', '%' . $filters['keep_short'] . '%');
|
||
}
|
||
if ($filters['keep_type'] !== '') {
|
||
$query->where(function ($effectiveType) use ($filters): void {
|
||
$effectiveType->where(function ($baseType) use ($filters): void {
|
||
$baseType->whereNull('type_override.id');
|
||
if ($filters['keep_type'] === '__empty__') {
|
||
$baseType->where(function ($blank): void {
|
||
$blank->whereNull('accounts.account_type')->orWhere('accounts.account_type', '');
|
||
});
|
||
} else {
|
||
$baseType->where('accounts.account_type', $filters['keep_type']);
|
||
}
|
||
})->orWhere(function ($overrideType) use ($filters): void {
|
||
$overrideType->whereNotNull('type_override.id');
|
||
if ($filters['keep_type'] === '__empty__') {
|
||
$overrideType->where(function ($blank): void {
|
||
$blank->whereNull('type_override.account_type')->orWhere('type_override.account_type', '');
|
||
});
|
||
} else {
|
||
$overrideType->where('type_override.account_type', $filters['keep_type']);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
if ($filters['keep_status'] !== '' && array_key_exists($filters['keep_status'], FinanceLedgerAccountStatus::OPTIONS)) {
|
||
if ($filters['keep_status'] === FinanceLedgerAccountStatus::NORMAL) {
|
||
$query->where(function ($normalStatus): void {
|
||
$normalStatus->whereNull('account_status.status')
|
||
->orWhere('account_status.status', FinanceLedgerAccountStatus::NORMAL);
|
||
});
|
||
} else {
|
||
$query->where('account_status.status', $filters['keep_status']);
|
||
}
|
||
}
|
||
if ($filters['keep_method'] !== '' && array_key_exists($filters['keep_method'], $handlingMethods)) {
|
||
$query->where('account_todo.handling_method', $filters['keep_method']);
|
||
}
|
||
if ($filters['keep_owner'] !== '') {
|
||
$query->where('account_todo.owner_name', 'like', '%' . $filters['keep_owner'] . '%');
|
||
}
|
||
if ($filters['keep_progress'] !== '' && array_key_exists($filters['keep_progress'], $completionStatuses)) {
|
||
if ($filters['keep_progress'] === 'not_started') {
|
||
$query->where(function ($notStarted): void {
|
||
$notStarted->whereNull('account_todo.current_progress')
|
||
->orWhere('account_todo.current_progress', 'not_started');
|
||
});
|
||
} else {
|
||
$query->where('account_todo.current_progress', $filters['keep_progress']);
|
||
}
|
||
}
|
||
|
||
$headerOptionRows = (clone $query)
|
||
->reorder()
|
||
->select([
|
||
'accounts.company_name',
|
||
'accounts.account_short',
|
||
'accounts.account_type',
|
||
'accounts.account_no',
|
||
'accounts.bank_name',
|
||
'accounts.bank_city_branch',
|
||
'account_status.status as effective_account_status',
|
||
'type_override.id as type_override_id',
|
||
'type_override.account_type as effective_account_type',
|
||
'account_todo.handling_method as todo_handling_method',
|
||
'account_todo.owner_name as todo_owner_name',
|
||
'account_todo.started_at as todo_started_at',
|
||
'account_todo.completed_at as todo_completed_at',
|
||
'account_todo.current_progress as todo_current_progress',
|
||
])
|
||
->get();
|
||
$companyNames = $headerOptionRows
|
||
->pluck('company_name')
|
||
->map(static fn ($name): string => trim((string) $name))
|
||
->filter()
|
||
->unique()
|
||
->values()
|
||
->all();
|
||
$legalRepresentatives = $this->latestLegalRepresentatives($connection, $companyNames);
|
||
$effectiveAccountTypes = $headerOptionRows->map(static function ($row): string {
|
||
$type = $row->type_override_id !== null
|
||
? $row->effective_account_type
|
||
: $row->account_type;
|
||
|
||
return trim((string) $type);
|
||
});
|
||
$typeOptions = FinanceLedgerAccountType::options($effectiveAccountTypes->all());
|
||
|
||
if ($headerFilterValue !== '') {
|
||
$textFilterColumns = [
|
||
'company_name' => 'accounts.company_name',
|
||
'account_short' => 'accounts.account_short',
|
||
'account_no' => 'accounts.account_no',
|
||
'bank_name' => 'accounts.bank_name',
|
||
'bank_city_branch' => 'accounts.bank_city_branch',
|
||
];
|
||
|
||
if (isset($textFilterColumns[$headerFilterColumn])) {
|
||
$query->where($textFilterColumns[$headerFilterColumn], 'like', '%' . $headerFilterValue . '%');
|
||
} elseif ($headerFilterColumn === 'legal_representative') {
|
||
$legalFilterValue = trim((string) preg_replace('/[((]\d+[))]\s*$/u', '', $headerFilterValue));
|
||
$allCompanyNames = $headerOptionRows
|
||
->pluck('company_name')
|
||
->map(static fn ($name): string => trim((string) $name))
|
||
->filter()
|
||
->unique()
|
||
->values();
|
||
$matchingEmptyValues = in_array($legalFilterValue, ['__empty__', '未填写'], true);
|
||
$matchingCompanies = $allCompanyNames
|
||
->filter(static function ($companyName) use ($legalRepresentatives, $legalFilterValue, $matchingEmptyValues): bool {
|
||
$representative = trim((string) ($legalRepresentatives[$companyName] ?? ''));
|
||
|
||
return $matchingEmptyValues
|
||
? $representative === ''
|
||
: str_contains($representative, $legalFilterValue);
|
||
})
|
||
->all();
|
||
if ($matchingCompanies === []) {
|
||
$query->whereRaw('1 = 0');
|
||
} else {
|
||
$query->whereIn('accounts.company_name', $matchingCompanies);
|
||
}
|
||
} elseif ($headerFilterColumn === 'account_type') {
|
||
$query->where(function ($effectiveType) use ($headerFilterValue): void {
|
||
$effectiveType->where(function ($baseType) use ($headerFilterValue): void {
|
||
$baseType->whereNull('type_override.id');
|
||
if ($headerFilterValue === '__empty__') {
|
||
$baseType->where(function ($blank): void {
|
||
$blank->whereNull('accounts.account_type')->orWhere('accounts.account_type', '');
|
||
});
|
||
} else {
|
||
$baseType->where('accounts.account_type', $headerFilterValue);
|
||
}
|
||
})->orWhere(function ($overrideType) use ($headerFilterValue): void {
|
||
$overrideType->whereNotNull('type_override.id');
|
||
if ($headerFilterValue === '__empty__') {
|
||
$overrideType->where(function ($blank): void {
|
||
$blank->whereNull('type_override.account_type')->orWhere('type_override.account_type', '');
|
||
});
|
||
} else {
|
||
$overrideType->where('type_override.account_type', $headerFilterValue);
|
||
}
|
||
});
|
||
});
|
||
} elseif ($headerFilterColumn === 'account_status') {
|
||
$status = array_search($headerFilterValue, FinanceLedgerAccountStatus::OPTIONS, true);
|
||
if ($status === false) {
|
||
$query->whereRaw('1 = 0');
|
||
} elseif ($status === FinanceLedgerAccountStatus::NORMAL) {
|
||
$query->where(function ($normalStatus): void {
|
||
$normalStatus->whereNull('account_status.status')
|
||
->orWhere('account_status.status', FinanceLedgerAccountStatus::NORMAL);
|
||
});
|
||
} else {
|
||
$query->where('account_status.status', $status);
|
||
}
|
||
} elseif ($headerFilterColumn === 'account_should_keep') {
|
||
if ($headerFilterValue !== FinanceLedgerAccountShouldKeep::NO) {
|
||
$query->whereRaw('1 = 0');
|
||
}
|
||
} elseif ($headerFilterColumn === 'handling_method') {
|
||
$method = array_search($headerFilterValue, $handlingMethods, true);
|
||
if ($method === false) {
|
||
$query->whereRaw('1 = 0');
|
||
} else {
|
||
$query->where('account_todo.handling_method', $method);
|
||
}
|
||
} elseif ($headerFilterColumn === 'owner_name') {
|
||
$query->where('account_todo.owner_name', 'like', '%' . $headerFilterValue . '%');
|
||
} elseif ($headerFilterColumn === 'started_at' || $headerFilterColumn === 'completed_at') {
|
||
$query->where('account_todo.' . $headerFilterColumn, 'like', $headerFilterValue . '%');
|
||
} elseif ($headerFilterColumn === 'current_progress') {
|
||
$progress = array_search($headerFilterValue, $completionStatuses, true);
|
||
if ($progress === false) {
|
||
$query->whereRaw('1 = 0');
|
||
} elseif ($progress === 'not_started') {
|
||
$query->where(function ($notStarted): void {
|
||
$notStarted->whereNull('account_todo.current_progress')
|
||
->orWhere('account_todo.current_progress', 'not_started');
|
||
});
|
||
} else {
|
||
$query->where('account_todo.current_progress', $progress);
|
||
}
|
||
}
|
||
}
|
||
|
||
$legalRepresentativeCountSource = in_array($headerFilterColumn, ['', 'legal_representative'], true)
|
||
? $headerOptionRows->pluck('company_name')
|
||
: (clone $query)
|
||
->reorder()
|
||
->select('accounts.company_name')
|
||
->pluck('accounts.company_name');
|
||
$summaryQuery = clone $query;
|
||
$summary = [
|
||
'count' => (clone $query)->count(),
|
||
'in_progress' => (clone $summaryQuery)->where('account_todo.current_progress', 'in_progress')->count(),
|
||
'not_started' => (clone $summaryQuery)->where(function ($notStarted): void {
|
||
$notStarted->whereNull('account_todo.current_progress')
|
||
->orWhere('account_todo.current_progress', 'not_started');
|
||
})->count(),
|
||
'completed' => (clone $summaryQuery)->where('account_todo.current_progress', 'completed')->count(),
|
||
];
|
||
|
||
$sortField = (string) $request->query('keep_sort', '');
|
||
$sortField = in_array($sortField, $headerFilterColumns, true) ? $sortField : '';
|
||
$sortDirection = strtolower((string) $request->query('keep_direction', 'asc'));
|
||
$sortDirection = $sortDirection === 'desc' ? 'desc' : 'asc';
|
||
if ($sortField !== '') {
|
||
if ($sortField === 'legal_representative') {
|
||
if ($connection->getSchemaBuilder()->hasTable('ops_fys_company_info')) {
|
||
$legalRepresentativeOrder = $connection->table('ops_fys_company_info')
|
||
->select('legalRepresentative')
|
||
->whereColumn('businessLicenseName', 'accounts.company_name')
|
||
->orderByDesc('Id')
|
||
->limit(1);
|
||
$query->orderBy($legalRepresentativeOrder, $sortDirection);
|
||
} else {
|
||
$query->orderBy('accounts.company_name', $sortDirection);
|
||
}
|
||
} elseif ($sortField === 'account_type') {
|
||
$query->orderByRaw(
|
||
'CASE WHEN type_override_id IS NOT NULL THEN effective_account_type'
|
||
. ' ELSE sort_account_type END ' . $sortDirection,
|
||
);
|
||
} elseif ($sortField === 'account_status') {
|
||
$query->orderByRaw(
|
||
"CASE COALESCE(effective_account_status, 'normal')"
|
||
. " WHEN 'normal' THEN 1"
|
||
. " WHEN 'online_banking_abnormal' THEN 2"
|
||
. " WHEN 'limited' THEN 3"
|
||
. " WHEN 'business_deregistered' THEN 4"
|
||
. ' ELSE 99 END ' . $sortDirection,
|
||
);
|
||
} elseif ($sortField === 'handling_method') {
|
||
$query->orderByRaw(
|
||
'CASE todo_handling_method'
|
||
. " WHEN 'online' THEN 1"
|
||
. " WHEN 'offline' THEN 2"
|
||
. ' ELSE 0 END ' . $sortDirection,
|
||
);
|
||
} elseif ($sortField === 'current_progress') {
|
||
$query->orderByRaw(
|
||
"CASE COALESCE(todo_current_progress, 'not_started')"
|
||
. " WHEN 'not_started' THEN 1"
|
||
. " WHEN 'in_progress' THEN 2"
|
||
. " WHEN 'completed' THEN 3"
|
||
. ' ELSE 99 END ' . $sortDirection,
|
||
);
|
||
} elseif ($sortField === 'owner_name') {
|
||
$query->orderBy('account_todo.owner_name', $sortDirection);
|
||
} elseif (in_array($sortField, ['started_at', 'completed_at'], true)) {
|
||
$query->orderBy('account_todo.' . $sortField, $sortDirection);
|
||
} elseif ($sortField !== 'account_should_keep') {
|
||
$query->orderBy('accounts.' . $sortField, $sortDirection);
|
||
}
|
||
}
|
||
|
||
$rows = $query
|
||
->orderBy('accounts.company_name')
|
||
->orderBy('accounts.account_short')
|
||
->paginate(50)
|
||
->appends($filters)
|
||
->appends(array_filter([
|
||
'keep_sort' => $sortField,
|
||
'keep_direction' => $sortField !== '' ? $sortDirection : '',
|
||
]))
|
||
->withQueryString();
|
||
|
||
$rows->getCollection()->transform(static function ($row) use ($legalRepresentatives) {
|
||
$row->account_status = FinanceLedgerAccountStatus::normalize($row->effective_account_status ?? null);
|
||
if ($row->type_override_id !== null) {
|
||
$row->account_type = (string) ($row->effective_account_type ?? '');
|
||
}
|
||
$row->todo_current_progress = $row->todo_current_progress ?: 'not_started';
|
||
$row->legal_representative = $legalRepresentatives[trim((string) $row->company_name)] ?? null;
|
||
|
||
return $row;
|
||
});
|
||
|
||
$ownerOptions = self::UNRETAINED_OWNER_OPTIONS;
|
||
$headerOptionValues = static function (iterable $values, bool $includeEmpty = false): array {
|
||
$normalized = collect($values)
|
||
->map(static fn ($value): string => trim((string) $value))
|
||
->filter(static fn (string $value): bool => $value !== '')
|
||
->unique()
|
||
->sort(SORT_NATURAL | SORT_FLAG_CASE)
|
||
->values()
|
||
->all();
|
||
$options = $normalized;
|
||
if ($includeEmpty) {
|
||
$options = array_merge(['__empty__' => '未填写'], $normalized);
|
||
}
|
||
|
||
return $options;
|
||
};
|
||
$hasEmpty = static fn (string $column): bool => $headerOptionRows
|
||
->contains(static fn ($row): bool => trim((string) ($row->{$column} ?? '')) === '');
|
||
$legalRepresentativeCounts = $legalRepresentativeCountSource->countBy(
|
||
static fn ($companyName): string => trim((string) ($legalRepresentatives[trim((string) $companyName)] ?? '')),
|
||
);
|
||
$legalRepresentativeOptions = [];
|
||
if (($legalRepresentativeCounts[''] ?? 0) > 0) {
|
||
$legalRepresentativeOptions[] = '未填写(' . $legalRepresentativeCounts[''] . ')';
|
||
}
|
||
$legalRepresentativeCounts
|
||
->except([''])
|
||
->sortKeys(SORT_NATURAL | SORT_FLAG_CASE)
|
||
->each(static function ($count, $name) use (&$legalRepresentativeOptions): void {
|
||
$legalRepresentativeOptions[] = $name . '(' . $count . ')';
|
||
});
|
||
$keepHeaderOptions = [
|
||
'company_name' => $headerOptionValues($headerOptionRows->pluck('company_name'), $hasEmpty('company_name')),
|
||
'account_short' => $headerOptionValues($headerOptionRows->pluck('account_short'), $hasEmpty('account_short')),
|
||
'legal_representative' => $legalRepresentativeOptions,
|
||
'account_type' => $headerOptionValues($typeOptions, true),
|
||
'account_status' => $headerOptionValues(array_values(FinanceLedgerAccountStatus::OPTIONS)),
|
||
'account_no' => $headerOptionValues($headerOptionRows->pluck('account_no'), $hasEmpty('account_no')),
|
||
'bank_name' => $headerOptionValues($headerOptionRows->pluck('bank_name'), $hasEmpty('bank_name')),
|
||
'bank_city_branch' => $headerOptionValues($headerOptionRows->pluck('bank_city_branch'), $hasEmpty('bank_city_branch')),
|
||
'account_should_keep' => [FinanceLedgerAccountShouldKeep::NO],
|
||
'handling_method' => $headerOptionValues(array_values($handlingMethods)),
|
||
'owner_name' => $headerOptionValues($ownerOptions),
|
||
'started_at' => [],
|
||
'completed_at' => [],
|
||
'current_progress' => $headerOptionValues(array_values($completionStatuses)),
|
||
];
|
||
$keepHeaderPreserve = collect($request->query())
|
||
->except(['page', 'keep_filter_column', 'keep_filter_value'])
|
||
->filter(static fn ($value): bool => is_scalar($value) || $value === null)
|
||
->all();
|
||
|
||
return view('business.pages.financial-ledger.unretained-account-tasks', [
|
||
'rows' => $rows,
|
||
'summary' => $summary,
|
||
'filters' => $filters,
|
||
'sortField' => $sortField,
|
||
'sortDirection' => $sortDirection,
|
||
'backUrl' => route('business.pages.show', [
|
||
'section' => 'financial-operations',
|
||
'page' => 'ledger',
|
||
]) . '?tab=accounts',
|
||
'canEdit' => FinanceLedgerAccess::canEditAccountStatus(user()),
|
||
'handlingMethods' => $handlingMethods,
|
||
'completionStatuses' => $completionStatuses,
|
||
'accountStatusOptions' => FinanceLedgerAccountStatus::OPTIONS,
|
||
'accountTypeOptions' => $typeOptions,
|
||
'ownerOptions' => $ownerOptions,
|
||
'keepHeaderOptions' => $keepHeaderOptions,
|
||
'keepHeaderPreserve' => $keepHeaderPreserve,
|
||
]);
|
||
}
|
||
|
||
public function updateAccountKeepTodo(Request $request)
|
||
{
|
||
abort_unless(FinanceLedgerAccess::canEditAccountStatus(user()), 403, '只有财务台账管理员可以修改不保留账户待办。');
|
||
|
||
$data = $request->validate([
|
||
'account_short' => ['required', 'string', 'max:100'],
|
||
'handling_method' => ['nullable', 'string', Rule::in(['online', 'offline'])],
|
||
'owner_name' => ['nullable', 'string', Rule::in(self::UNRETAINED_OWNER_OPTIONS)],
|
||
'started_at' => ['nullable', 'date'],
|
||
'completed_at' => ['nullable', 'date'],
|
||
'current_progress' => ['required', 'string', Rule::in(['completed', 'in_progress', 'not_started'])],
|
||
]);
|
||
|
||
$accountShort = trim((string) $data['account_short']);
|
||
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||
$account = $connection->table('fin_accounts')
|
||
->where('account_short', $accountShort)
|
||
->first(['account_should_keep', 'company_plan']);
|
||
abort_if($account === null, 422, '账户简称不存在,请刷新待办清单后重试。');
|
||
abort_unless(
|
||
FinanceLedgerAccountShouldKeep::display(
|
||
$account->account_should_keep ?? null,
|
||
$account->company_plan ?? null,
|
||
) === FinanceLedgerAccountShouldKeep::NO,
|
||
422,
|
||
'账户当前已设置为保留,无需继续填写不保留账户待办。',
|
||
);
|
||
|
||
$now = now();
|
||
$values = [
|
||
'handling_method' => $data['handling_method'] ?? null,
|
||
'owner_name' => $data['owner_name'] ?? null,
|
||
'started_at' => filled($data['started_at'] ?? null)
|
||
? Carbon::parse($data['started_at'])->format('Y-m-d H:i:s')
|
||
: null,
|
||
'completed_at' => filled($data['completed_at'] ?? null)
|
||
? Carbon::parse($data['completed_at'])->format('Y-m-d H:i:s')
|
||
: null,
|
||
'current_progress' => (string) $data['current_progress'],
|
||
'updated_by' => (string) user()->name,
|
||
'updated_at' => $now,
|
||
];
|
||
|
||
$todos = $connection->table('fin_account_keep_todos');
|
||
$existing = $todos->where('account_short', $accountShort)->exists();
|
||
if ($existing) {
|
||
$todos->where('account_short', $accountShort)->update($values);
|
||
} else {
|
||
$todos->insert($values + [
|
||
'account_short' => $accountShort,
|
||
'created_at' => $now,
|
||
]);
|
||
}
|
||
|
||
return redirect()->back()->with('success', '不保留账户待办已保存');
|
||
}
|
||
|
||
public function updateAccountTodo(Request $request, FinanceLedgerCalculator $calculator)
|
||
{
|
||
abort_unless(FinanceLedgerAccess::canEditAccountStatus(user()), 403, '只有财务台账管理员可以修改待办事项。');
|
||
|
||
$data = $request->validate([
|
||
'account_short' => ['required', 'string', 'max:100'],
|
||
'abnormal_reason' => ['nullable', 'string', 'max:1000'],
|
||
'handling_method' => ['nullable', 'string', Rule::in(['online', 'offline'])],
|
||
'owner_name' => ['nullable', 'string', 'max:100'],
|
||
'started_at' => ['nullable', 'date'],
|
||
'completed_at' => ['nullable', 'date'],
|
||
'current_progress' => ['nullable', 'string', 'max:100'],
|
||
'completion_status' => ['nullable', 'string', Rule::in(['completed', 'in_progress', 'not_started', 'incomplete'])],
|
||
]);
|
||
|
||
$accountShort = trim((string) $data['account_short']);
|
||
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||
abort_unless(
|
||
$connection->table('fin_accounts')->where('account_short', $accountShort)->exists(),
|
||
422,
|
||
'账户简称不存在,请刷新待办清单后重试。',
|
||
);
|
||
|
||
$abnormalStatuses = array_values(array_diff(
|
||
array_keys(FinanceLedgerAccountStatus::OPTIONS),
|
||
[FinanceLedgerAccountStatus::NORMAL],
|
||
));
|
||
$status = $connection->table('fin_account_statuses')
|
||
->where('account_short', $accountShort)
|
||
->value('status');
|
||
abort_unless(
|
||
$status !== null && in_array(FinanceLedgerAccountStatus::normalize((string) $status), $abnormalStatuses, true),
|
||
422,
|
||
'账户当前已不是异常状态,无需继续填写待办。',
|
||
);
|
||
|
||
$now = now();
|
||
$completionStatus = (string) ($data['completion_status'] ?? 'not_started');
|
||
$values = [
|
||
'abnormal_reason' => $data['abnormal_reason'] ?? null,
|
||
'handling_method' => $data['handling_method'] ?? null,
|
||
'owner_name' => $data['owner_name'] ?? null,
|
||
'started_at' => filled($data['started_at'] ?? null)
|
||
? Carbon::parse($data['started_at'])->format('Y-m-d H:i:s')
|
||
: null,
|
||
'completed_at' => filled($data['completed_at'] ?? null)
|
||
? Carbon::parse($data['completed_at'])->format('Y-m-d H:i:s')
|
||
: null,
|
||
'current_progress' => $data['current_progress'] ?? null,
|
||
'completion_status' => $completionStatus,
|
||
'updated_by' => (string) user()->name,
|
||
'updated_at' => $now,
|
||
];
|
||
$connection->transaction(function () use ($connection, $accountShort, $values, $now, $completionStatus, $calculator): void {
|
||
$todos = $connection->table('fin_account_todos');
|
||
$existing = $todos->where('account_short', $accountShort)->exists();
|
||
if ($existing) {
|
||
$todos->where('account_short', $accountShort)->update($values);
|
||
} else {
|
||
$todos->insert($values + [
|
||
'account_short' => $accountShort,
|
||
'created_at' => $now,
|
||
]);
|
||
}
|
||
|
||
if ($completionStatus === 'completed') {
|
||
$connection->table('fin_account_statuses')
|
||
->where('account_short', $accountShort)
|
||
->update([
|
||
'status' => FinanceLedgerAccountStatus::NORMAL,
|
||
'updated_by' => (string) user()->name,
|
||
'updated_at' => $now,
|
||
]);
|
||
$calculator->refreshAccountAmounts($accountShort);
|
||
}
|
||
});
|
||
|
||
return redirect()->back()->with(
|
||
'success',
|
||
$completionStatus === 'completed'
|
||
? '待办已完成,账户状态已自动设为正常'
|
||
: '待办事项已保存',
|
||
);
|
||
}
|
||
|
||
public function updateMapping(Request $request, int $id, FinanceLedgerCalculator $calculator): JsonResponse
|
||
{
|
||
abort_unless(FinanceLedgerAccess::canEditMapping(user()), 403, '只有财务台账管理员可以修改账户映射。');
|
||
|
||
$data = $request->validate([
|
||
'account_short' => ['nullable', 'string', 'max:100'],
|
||
]);
|
||
|
||
$account = trim((string) ($data['account_short'] ?? ''));
|
||
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||
$row = $connection->table('fin_account_map')->where('id', $id)->first();
|
||
abort_if($row === null, 404);
|
||
|
||
if ($account !== '') {
|
||
$exists = $connection->table('fin_accounts')->where('account_short', $account)->exists();
|
||
abort_unless($exists, 422, '账户简称不存在,请从账户总表中选择。');
|
||
}
|
||
|
||
$old = (string) ($row->account_short ?? '');
|
||
$connection->table('fin_account_map')->where('id', $id)->update([
|
||
'account_short' => $account !== '' ? $account : null,
|
||
'updated_by' => (string) user()->name,
|
||
'updated_at' => now(),
|
||
]);
|
||
$connection->table('fin_account_map_events')->insert([
|
||
'source' => (string) $row->source,
|
||
'source_value' => (string) $row->source_value,
|
||
'old_account_short' => $old !== '' ? $old : null,
|
||
'new_account_short' => $account !== '' ? $account : null,
|
||
'user_id' => (int) user()->id,
|
||
'user_name' => (string) user()->name,
|
||
'created_at' => now(),
|
||
]);
|
||
|
||
if ($request->boolean('recalculate', true)) {
|
||
$calculator->recalculate();
|
||
}
|
||
|
||
$mapped = $connection->table('fin_account_map')
|
||
->whereNotNull('account_short')->where('account_short', '<>', '')->count();
|
||
|
||
return response()->json([
|
||
'ok' => true,
|
||
'account_short' => $account,
|
||
'mapped' => $mapped,
|
||
'total' => $connection->table('fin_account_map')->count(),
|
||
], 200, ['Cache-Control' => 'private, no-store']);
|
||
}
|
||
|
||
public function storeManualTransaction(
|
||
Request $request,
|
||
FinanceLedgerCalculator $calculator,
|
||
FinanceLedgerPageData $pageData,
|
||
): JsonResponse
|
||
{
|
||
abort_unless(FinanceLedgerAccess::canEditMapping(user()), 403, '只有财务台账管理员可以新增手工动账明细。');
|
||
|
||
$openingDate = (string) config('finance_ledger.opening_date', '2026-10-17');
|
||
$data = $request->validate([
|
||
'idempotency_key' => ['required', 'uuid'],
|
||
'txn_date' => ['required', 'date', 'after_or_equal:' . $openingDate],
|
||
'account_short' => ['required', 'string', 'max:100'],
|
||
'detail' => ['required', 'string', 'max:4000'],
|
||
'income' => ['nullable', 'numeric', 'min:0', 'max:9999999999999999.99'],
|
||
'expense' => ['nullable', 'numeric', 'min:0', 'max:9999999999999999.99'],
|
||
'approval_no' => ['nullable', 'string', 'max:100'],
|
||
]);
|
||
|
||
$accountShort = trim((string) $data['account_short']);
|
||
$income = round((float) ($data['income'] ?? 0), 2);
|
||
$expense = round((float) ($data['expense'] ?? 0), 2);
|
||
if (($income > 0) === ($expense > 0)) {
|
||
throw ValidationException::withMessages([
|
||
'income' => '收入和支出只能填写一项。',
|
||
]);
|
||
}
|
||
|
||
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||
$account = $connection->table('fin_accounts')
|
||
->where('account_short', $accountShort)
|
||
->orderBy('id')
|
||
->first(['account_short', 'company_name']);
|
||
abort_if($account === null, 422, '账户简称不存在,请从账户总表中选择。');
|
||
|
||
$idempotencyKey = (string) $data['idempotency_key'];
|
||
$rowKey = hash('sha256', FinanceLedgerTransactionWriter::MANUAL_SOURCE . ':' . $idempotencyKey);
|
||
$detail = preg_replace('/\s+/u', ' ', trim((string) $data['detail'])) ?? trim((string) $data['detail']);
|
||
$approvalNo = trim((string) ($data['approval_no'] ?? ''));
|
||
if ($approvalNo === '') {
|
||
$approvalNo = 'MAN-' . str_replace('-', '', (string) $data['txn_date']) . '-' . strtoupper(substr($rowKey, 0, 6));
|
||
}
|
||
|
||
$inserted = $connection->transaction(function () use (
|
||
$connection,
|
||
$calculator,
|
||
$rowKey,
|
||
$data,
|
||
$accountShort,
|
||
$detail,
|
||
$income,
|
||
$expense,
|
||
$approvalNo,
|
||
): bool {
|
||
$now = now();
|
||
$created = $connection->table('fin_transactions')->insertOrIgnore([
|
||
'txn_date' => $data['txn_date'],
|
||
'account_short' => $accountShort,
|
||
'detail' => $detail,
|
||
'income' => $income,
|
||
'expense' => $expense,
|
||
'approval_no' => mb_substr($approvalNo, 0, 100),
|
||
'source' => FinanceLedgerTransactionWriter::MANUAL_SOURCE,
|
||
'source_value' => null,
|
||
'process_type' => '手工录入',
|
||
'row_key' => $rowKey,
|
||
'synced_at' => $now,
|
||
'created_at' => $now,
|
||
'updated_at' => $now,
|
||
'created_by' => (string) user()->name,
|
||
'updated_by' => (string) user()->name,
|
||
]) === 1;
|
||
|
||
if ($created) {
|
||
$calculator->recalculate();
|
||
}
|
||
|
||
return $created;
|
||
});
|
||
|
||
$row = $connection->table('fin_transactions')->where('row_key', $rowKey)->first();
|
||
abort_if($row === null, 500, '手工动账明细保存失败,请刷新后重试。');
|
||
|
||
$accountInfo = $connection->table('fin_accounts')
|
||
->where('account_short', $accountShort)
|
||
->orderBy('id')
|
||
->first(['account_short', 'company_name', 'current_balance']);
|
||
$flow = $connection->table('fin_daily_flows')
|
||
->where('account_short', $accountShort)
|
||
->whereDate('flow_date', (string) $row->txn_date)
|
||
->first(['flow_date', 'opening_balance', 'inflow', 'outflow', 'closing_balance']);
|
||
return response()->json([
|
||
'ok' => true,
|
||
'created' => $inserted,
|
||
'row' => [
|
||
'id' => (int) $row->id,
|
||
'txn_date' => (string) $row->txn_date,
|
||
'account_short' => (string) $row->account_short,
|
||
'company_name' => (string) ($accountInfo->company_name ?? ''),
|
||
'detail' => (string) $row->detail,
|
||
'source' => FinanceLedgerTransactionWriter::MANUAL_SOURCE,
|
||
'source_label' => '手工录入',
|
||
'income' => round((float) $row->income, 2),
|
||
'expense' => round((float) $row->expense, 2),
|
||
'approval_no' => (string) ($row->approval_no ?? ''),
|
||
],
|
||
'account' => [
|
||
'account_short' => (string) ($accountInfo->account_short ?? $accountShort),
|
||
'company_name' => (string) ($accountInfo->company_name ?? ''),
|
||
'current_balance' => round((float) ($accountInfo->current_balance ?? 0), 2),
|
||
],
|
||
'daily_flow' => $flow === null ? null : [
|
||
'flow_date' => (string) $flow->flow_date,
|
||
'opening_balance' => round((float) $flow->opening_balance, 2),
|
||
'inflow' => round((float) $flow->inflow, 2),
|
||
'outflow' => round((float) $flow->outflow, 2),
|
||
'closing_balance' => round((float) $flow->closing_balance, 2),
|
||
],
|
||
'summary' => $pageData->transactionSummary(),
|
||
'total_transactions' => $connection->table('fin_transactions')->count(),
|
||
'next_idempotency_key' => (string) Str::uuid(),
|
||
], $inserted ? 201 : 200, ['Cache-Control' => 'private, no-store']);
|
||
}
|
||
|
||
public function updateTransactionAccount(Request $request, int $id, FinanceLedgerCalculator $calculator): JsonResponse
|
||
{
|
||
abort_unless(FinanceLedgerAccess::canEditMapping(user()), 403, '只有财务台账管理员可以修改动账账户关联。');
|
||
|
||
$data = $request->validate([
|
||
'account_short' => ['required', 'string', 'max:100'],
|
||
]);
|
||
|
||
$accountShort = trim((string) $data['account_short']);
|
||
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||
$account = $connection->table('fin_accounts')
|
||
->where('account_short', $accountShort)
|
||
->orderBy('id')
|
||
->first(['account_short', 'company_name']);
|
||
abort_if($account === null, 422, '账户简称不存在,请从账户总表中选择。');
|
||
|
||
$result = $connection->transaction(function () use (
|
||
$connection,
|
||
$calculator,
|
||
$id,
|
||
$accountShort,
|
||
): array {
|
||
$transaction = $connection->table('fin_transactions')
|
||
->where('id', $id)
|
||
->lockForUpdate()
|
||
->first();
|
||
abort_if($transaction === null, 404);
|
||
|
||
$source = trim((string) $transaction->source);
|
||
$sourceValue = trim((string) ($transaction->source_value ?? ''));
|
||
$directTransaction = $source === FinanceLedgerTransactionWriter::MANUAL_SOURCE || $sourceValue === '';
|
||
$groupKey = $directTransaction
|
||
? 'transaction:' . $id
|
||
: hash('sha256', $source . '|' . $sourceValue);
|
||
$mapping = null;
|
||
$old = trim((string) ($transaction->account_short ?? ''));
|
||
|
||
if (! $directTransaction) {
|
||
$mapping = $connection->table('fin_account_map')
|
||
->where('source', $source)
|
||
->where('source_value', $sourceValue)
|
||
->lockForUpdate()
|
||
->first();
|
||
$mappedAccount = trim((string) ($mapping->account_short ?? ''));
|
||
if ($mappedAccount !== '') {
|
||
$old = $mappedAccount;
|
||
}
|
||
}
|
||
|
||
if ($old === $accountShort) {
|
||
return [
|
||
'changed' => false,
|
||
'scope' => $directTransaction ? 'transaction' : 'mapping',
|
||
'group_key' => $groupKey,
|
||
'txn_date' => (string) $transaction->txn_date,
|
||
];
|
||
}
|
||
|
||
$now = now();
|
||
if ($directTransaction) {
|
||
$connection->table('fin_transactions')->where('id', $id)->update([
|
||
'account_short' => $accountShort,
|
||
'updated_by' => (string) user()->name,
|
||
'updated_at' => $now,
|
||
]);
|
||
} else {
|
||
if ($mapping === null) {
|
||
$stats = $connection->table('fin_transactions')
|
||
->where('source', $source)
|
||
->where('source_value', $sourceValue)
|
||
->selectRaw('COUNT(*) AS txn_count, SUM(income) AS income_sum, SUM(expense) AS expense_sum')
|
||
->first();
|
||
$connection->table('fin_account_map')->insert([
|
||
'source' => $source,
|
||
'source_value' => $sourceValue,
|
||
'account_short' => $accountShort,
|
||
'txn_count' => (int) ($stats->txn_count ?? 0),
|
||
'income_sum' => round((float) ($stats->income_sum ?? 0), 2),
|
||
'expense_sum' => round((float) ($stats->expense_sum ?? 0), 2),
|
||
'updated_by' => (string) user()->name,
|
||
'created_at' => $now,
|
||
'updated_at' => $now,
|
||
]);
|
||
} else {
|
||
$connection->table('fin_account_map')->where('id', $mapping->id)->update([
|
||
'account_short' => $accountShort,
|
||
'updated_by' => (string) user()->name,
|
||
'updated_at' => $now,
|
||
]);
|
||
}
|
||
|
||
$connection->table('fin_account_map_events')->insert([
|
||
'source' => $source,
|
||
'source_value' => $sourceValue,
|
||
'old_account_short' => $old !== '' ? $old : null,
|
||
'new_account_short' => $accountShort,
|
||
'user_id' => (int) user()->id,
|
||
'user_name' => (string) user()->name,
|
||
'created_at' => $now,
|
||
]);
|
||
}
|
||
|
||
$calculator->recalculate();
|
||
|
||
return [
|
||
'changed' => true,
|
||
'scope' => $directTransaction ? 'transaction' : 'mapping',
|
||
'group_key' => $groupKey,
|
||
'txn_date' => (string) $transaction->txn_date,
|
||
];
|
||
});
|
||
|
||
$flow = $connection->table('fin_daily_flows')
|
||
->where('account_short', $accountShort)
|
||
->whereDate('flow_date', (string) $result['txn_date'])
|
||
->first(['flow_date', 'opening_balance', 'inflow', 'outflow', 'closing_balance']);
|
||
$currentBalance = $connection->table('fin_accounts')
|
||
->where('account_short', $accountShort)
|
||
->orderBy('id')
|
||
->value('current_balance');
|
||
|
||
return response()->json([
|
||
'ok' => true,
|
||
'changed' => (bool) $result['changed'],
|
||
'scope' => (string) $result['scope'],
|
||
'group_key' => (string) $result['group_key'],
|
||
'account_short' => $accountShort,
|
||
'company_name' => (string) ($account->company_name ?? ''),
|
||
'current_balance' => round((float) ($currentBalance ?? 0), 2),
|
||
'daily_flow' => $flow === null ? null : [
|
||
'flow_date' => (string) $flow->flow_date,
|
||
'opening_balance' => round((float) $flow->opening_balance, 2),
|
||
'inflow' => round((float) $flow->inflow, 2),
|
||
'outflow' => round((float) $flow->outflow, 2),
|
||
'closing_balance' => round((float) $flow->closing_balance, 2),
|
||
],
|
||
], 200, ['Cache-Control' => 'private, no-store']);
|
||
}
|
||
|
||
public function updateAccountStatus(Request $request, FinanceLedgerCalculator $calculator): JsonResponse
|
||
{
|
||
abort_unless(FinanceLedgerAccess::canEditAccountStatus(user()), 403, '只有财务台账管理员可以修改账户状态。');
|
||
|
||
$data = $request->validate([
|
||
'account_short' => ['required', 'string', 'max:100'],
|
||
'status' => ['required', 'string', Rule::in(array_keys(FinanceLedgerAccountStatus::OPTIONS))],
|
||
]);
|
||
|
||
$accountShort = trim((string) $data['account_short']);
|
||
abort_if($accountShort === '', 422, '账户简称不能为空。');
|
||
|
||
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||
abort_unless(
|
||
$connection->table('fin_accounts')->where('account_short', $accountShort)->exists(),
|
||
422,
|
||
'账户简称不存在,请刷新账户总表后重试。',
|
||
);
|
||
|
||
$status = FinanceLedgerAccountStatus::normalize((string) $data['status']);
|
||
$statuses = $connection->table('fin_account_statuses');
|
||
$existing = $statuses->where('account_short', $accountShort)->first();
|
||
$now = now();
|
||
$values = [
|
||
'status' => $status,
|
||
'updated_by' => (string) user()->name,
|
||
'updated_at' => $now,
|
||
];
|
||
|
||
$connection->transaction(function () use ($statuses, $existing, $values, $accountShort, $now, $calculator): void {
|
||
if ($existing === null) {
|
||
$statuses->insert($values + [
|
||
'account_short' => $accountShort,
|
||
'created_at' => $now,
|
||
]);
|
||
} else {
|
||
$statuses->where('account_short', $accountShort)->update($values);
|
||
}
|
||
|
||
$calculator->refreshAccountAmounts($accountShort);
|
||
});
|
||
|
||
return response()->json([
|
||
'ok' => true,
|
||
'account_short' => $accountShort,
|
||
'status' => $status,
|
||
'label' => FinanceLedgerAccountStatus::label($status),
|
||
], 200, ['Cache-Control' => 'private, no-store']);
|
||
}
|
||
|
||
public function updateAccountType(Request $request): JsonResponse
|
||
{
|
||
abort_unless(FinanceLedgerAccess::canEditAccountType(user()), 403, '只有财务台账管理员可以修改账户类型。');
|
||
|
||
$data = $request->validate([
|
||
'account_short' => ['required', 'string', 'max:100'],
|
||
'account_type' => ['nullable', 'string', 'max:50'],
|
||
]);
|
||
|
||
$accountShort = trim((string) $data['account_short']);
|
||
$accountType = trim((string) ($data['account_type'] ?? ''));
|
||
abort_if($accountShort === '', 422, '账户简称不能为空。');
|
||
|
||
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||
$account = $connection->table('fin_accounts')
|
||
->where('account_short', $accountShort)
|
||
->first(['account_no', 'account_last4']);
|
||
abort_if($account === null, 422, '账户简称不存在,请刷新账户总表后重试。');
|
||
|
||
if ($accountType !== '') {
|
||
$allowedTypes = FinanceLedgerAccountType::options(
|
||
$connection->table('fin_accounts')
|
||
->whereNotNull('account_type')->where('account_type', '<>', '')
|
||
->distinct()->pluck('account_type')->all(),
|
||
$connection->table('fin_account_type_overrides')
|
||
->whereNotNull('account_type')->where('account_type', '<>', '')
|
||
->distinct()->pluck('account_type')->all(),
|
||
);
|
||
abort_unless(in_array($accountType, $allowedTypes, true), 422, '请选择现有账户类型。');
|
||
}
|
||
|
||
$overrides = $connection->table('fin_account_type_overrides');
|
||
$existing = $overrides->where('account_short', $accountShort)->first();
|
||
$now = now();
|
||
$values = [
|
||
'account_type' => $accountType !== '' ? $accountType : null,
|
||
'updated_by' => (string) user()->name,
|
||
'updated_at' => $now,
|
||
];
|
||
$accountLast4 = FinanceLedgerAccountType::last4(
|
||
$accountType,
|
||
(string) ($account->account_no ?? ''),
|
||
(string) ($account->account_last4 ?? ''),
|
||
);
|
||
|
||
$connection->transaction(function () use ($connection, $overrides, $existing, $values, $accountShort, $now, $accountLast4): void {
|
||
if ($existing === null) {
|
||
$overrides->insert($values + [
|
||
'account_short' => $accountShort,
|
||
'created_at' => $now,
|
||
]);
|
||
} else {
|
||
$overrides->where('account_short', $accountShort)->update($values);
|
||
}
|
||
|
||
$connection->table('fin_accounts')
|
||
->where('account_short', $accountShort)
|
||
->update([
|
||
'account_last4' => $accountLast4,
|
||
'updated_at' => $now,
|
||
]);
|
||
});
|
||
|
||
return response()->json([
|
||
'ok' => true,
|
||
'account_short' => $accountShort,
|
||
'account_type' => $accountType,
|
||
'label' => $accountType !== '' ? $accountType : '未填写',
|
||
'account_last4' => $accountLast4,
|
||
], 200, ['Cache-Control' => 'private, no-store']);
|
||
}
|
||
|
||
public function updateAccountPlan(Request $request): JsonResponse
|
||
{
|
||
abort_unless(FinanceLedgerAccess::canEditAccountStatus(user()), 403, '只有财务台账管理员可以修改公司规划。');
|
||
|
||
$data = $request->validate([
|
||
'account_short' => ['required', 'string', 'max:100'],
|
||
'company_plan' => ['nullable', 'string', 'max:50'],
|
||
]);
|
||
$accountShort = trim((string) $data['account_short']);
|
||
$plan = trim((string) ($data['company_plan'] ?? ''));
|
||
if ($accountShort === '') {
|
||
abort(422, '账户简称不能为空。');
|
||
}
|
||
|
||
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||
abort_unless(
|
||
$connection->table('fin_accounts')->where('account_short', $accountShort)->exists(),
|
||
422,
|
||
'账户简称不存在,请刷新账户总表后重试。',
|
||
);
|
||
$allowedPlans = $connection->table('fin_accounts')
|
||
->whereNotNull('company_plan')
|
||
->where('company_plan', '<>', '')
|
||
->distinct()
|
||
->pluck('company_plan')
|
||
->map(static fn (mixed $value): string => trim((string) $value))
|
||
->filter()
|
||
->values()
|
||
->all();
|
||
abort_unless($plan === '' || in_array($plan, $allowedPlans, true), 422, '请选择现有公司规划。');
|
||
|
||
$connection->table('fin_accounts')->where('account_short', $accountShort)->update([
|
||
'company_plan' => $plan !== '' ? $plan : null,
|
||
'updated_at' => now(),
|
||
]);
|
||
|
||
return response()->json([
|
||
'ok' => true,
|
||
'account_short' => $accountShort,
|
||
'company_plan' => $plan,
|
||
'label' => $plan !== '' ? $plan : '未填写',
|
||
], 200, ['Cache-Control' => 'private, no-store']);
|
||
}
|
||
|
||
public function updateAccountKeep(Request $request): JsonResponse
|
||
{
|
||
abort_unless(FinanceLedgerAccess::canEditAccountStatus(user()), 403, '只有财务台账管理员可以修改账户是否保留。');
|
||
|
||
$data = $request->validate([
|
||
'account_short' => ['required', 'string', 'max:100'],
|
||
'account_should_keep' => ['nullable', 'string', Rule::in(FinanceLedgerAccountShouldKeep::OPTIONS)],
|
||
]);
|
||
$accountShort = trim((string) $data['account_short']);
|
||
$keep = FinanceLedgerAccountShouldKeep::normalize((string) ($data['account_should_keep'] ?? ''));
|
||
if ($accountShort === '') {
|
||
abort(422, '账户简称不能为空。');
|
||
}
|
||
|
||
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||
abort_unless(
|
||
$connection->table('fin_accounts')->where('account_short', $accountShort)->exists(),
|
||
422,
|
||
'账户简称不存在,请刷新账户总表后重试。',
|
||
);
|
||
|
||
$connection->table('fin_accounts')->where('account_short', $accountShort)->update([
|
||
'account_should_keep' => $keep !== '' ? $keep : null,
|
||
'updated_at' => now(),
|
||
]);
|
||
|
||
return response()->json([
|
||
'ok' => true,
|
||
'account_short' => $accountShort,
|
||
'account_should_keep' => $keep,
|
||
'label' => $keep !== '' ? $keep : '未填写',
|
||
], 200, ['Cache-Control' => 'private, no-store']);
|
||
}
|
||
|
||
public function previewShopMapping(FinanceLedgerShopMappingBulk $bulk): JsonResponse
|
||
{
|
||
abort_unless(FinanceLedgerAccess::canEditMapping(user()), 403, '只有财务台账管理员可以批量修改账户映射。');
|
||
|
||
$preview = $bulk->preview();
|
||
|
||
return response()->json([
|
||
'ok' => true,
|
||
'total' => $preview['total'],
|
||
'matched' => $preview['matched'],
|
||
'unmatched' => $preview['unmatched'],
|
||
'sample' => array_slice($preview['candidates'], 0, 10),
|
||
'unmatched_sample' => array_slice($preview['unmatched_values'], 0, 10),
|
||
], 200, ['Cache-Control' => 'private, no-store']);
|
||
}
|
||
|
||
public function applyShopMapping(FinanceLedgerShopMappingBulk $bulk): JsonResponse
|
||
{
|
||
abort_unless(FinanceLedgerAccess::canEditMapping(user()), 403, '只有财务台账管理员可以批量修改账户映射。');
|
||
|
||
$result = $bulk->apply(user());
|
||
|
||
return response()->json([
|
||
'ok' => true,
|
||
'total' => $result['total'],
|
||
'mapped' => $result['mapped'],
|
||
'unmatched' => $result['unmatched'],
|
||
], 200, ['Cache-Control' => 'private, no-store']);
|
||
}
|
||
|
||
public function previewPayableMapping(FinanceLedgerPayableMappingBulk $bulk): JsonResponse
|
||
{
|
||
abort_unless(FinanceLedgerAccess::canEditMapping(user()), 403, '只有财务台账管理员可以批量修改账户映射。');
|
||
|
||
$preview = $bulk->preview();
|
||
|
||
return response()->json([
|
||
'ok' => true,
|
||
'total' => $preview['total'],
|
||
'eligible' => $preview['eligible'],
|
||
'ambiguous' => $preview['ambiguous'],
|
||
'missing' => $preview['missing'],
|
||
'sample' => array_slice($preview['candidates'], 0, 10),
|
||
'ambiguous_sample' => array_slice($preview['ambiguous_values'], 0, 10),
|
||
'missing_sample' => array_slice($preview['missing_values'], 0, 10),
|
||
], 200, ['Cache-Control' => 'private, no-store']);
|
||
}
|
||
|
||
public function applyPayableMapping(FinanceLedgerPayableMappingBulk $bulk): JsonResponse
|
||
{
|
||
abort_unless(FinanceLedgerAccess::canEditMapping(user()), 403, '只有财务台账管理员可以批量修改账户映射。');
|
||
|
||
$result = $bulk->apply(user());
|
||
|
||
return response()->json([
|
||
'ok' => true,
|
||
'total' => $result['total'],
|
||
'mapped' => $result['mapped'],
|
||
'ambiguous' => $result['ambiguous'],
|
||
'missing' => $result['missing'],
|
||
], 200, ['Cache-Control' => 'private, no-store']);
|
||
}
|
||
|
||
private function latestLegalRepresentatives($connection, array $companyNames): array
|
||
{
|
||
if ($companyNames === [] || ! $connection->getSchemaBuilder()->hasTable('ops_fys_company_info')) {
|
||
return [];
|
||
}
|
||
|
||
$latestCompanyIds = $connection->table('ops_fys_company_info')
|
||
->selectRaw('businessLicenseName, MAX(Id) AS latest_id')
|
||
->whereIn('businessLicenseName', $companyNames)
|
||
->groupBy('businessLicenseName')
|
||
->pluck('latest_id', 'businessLicenseName');
|
||
|
||
if ($latestCompanyIds->isEmpty()) {
|
||
return [];
|
||
}
|
||
|
||
return $connection->table('ops_fys_company_info')
|
||
->whereIn('Id', $latestCompanyIds->values()->all())
|
||
->get(['businessLicenseName', 'legalRepresentative'])
|
||
->mapWithKeys(static fn ($company): array => [
|
||
(string) $company->businessLicenseName => trim((string) $company->legalRepresentative),
|
||
])
|
||
->all();
|
||
}
|
||
}
|