diff --git a/app/Business/TableRegistry.php b/app/Business/TableRegistry.php index def5f902..11d3a1d6 100644 --- a/app/Business/TableRegistry.php +++ b/app/Business/TableRegistry.php @@ -12,6 +12,7 @@ use App\Models\Business\CompanyStoreData; use App\Models\Business\ExpressReceiving; use App\Models\Business\FundForecast; use App\Models\Business\InvoiceBasic; +use App\Models\Business\InstallationDetail; use App\Models\Business\StoreChange; use App\Models\Business\StoreChangeLog; use App\Models\Business\StoreClosing; @@ -170,6 +171,7 @@ class TableRegistry 'fund-management/bank-accounts' => BankAccount::class, 'fund-management/shared-company-accounts' => BankAccount::class, // 重用 BankAccount,仅类型过滤 'billing-transactions/store-receiving-balance' => StoreReceivingAccount::class, + 'financial-operations/installation-details' => InstallationDetail::class, 'financial-risk-control/process-statistics' => ShopInfoFlow::class, ]; } @@ -259,6 +261,7 @@ class TableRegistry 'store-management/store-accounts', 'store-management/store-report', 'trademark-management/information', + 'financial-operations/installation-details', 'financial-risk-control/process-statistics', ], true); } diff --git a/app/Business/UpstreamBusinessInformation.php b/app/Business/UpstreamBusinessInformation.php index c56a449b..837dc4a3 100644 --- a/app/Business/UpstreamBusinessInformation.php +++ b/app/Business/UpstreamBusinessInformation.php @@ -7,6 +7,7 @@ use InvalidArgumentException; use Modules\EcommerceFinance\Repositories\BusinessInformationMirrorRepository; use Modules\EcommerceFinance\Services\UpstreamQueryCacheFactory; use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery; +use Modules\EcommerceFinance\Repositories\InstallationDetailMirrorRepository; use Modules\EcommerceFinance\Repositories\StoreReportRepository; use Modules\EcommerceFinance\Repositories\StoreAccountMirrorRepository; use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException; @@ -22,6 +23,7 @@ class UpstreamBusinessInformation // 兼容仍按旧构造方式调用的代码;容器解析时会正常注入仓储。 private readonly ?StoreReportRepository $storeReports = null, private readonly ?StoreAccountMirrorRepository $storeAccounts = null, + private readonly ?InstallationDetailMirrorRepository $installationMirror = null, ) {} public function paginate(string $section, string $page, int $perPage = 50): LengthAwarePaginator @@ -37,6 +39,7 @@ class UpstreamBusinessInformation 'store-management/store-report' => $this->storeReportPage($currentPage, $perPage), 'company-management/subject-operating-report' => $this->subjectOperatingReportPage($currentPage, $perPage), 'trademark-management/information' => $this->trademarkPage($currentPage, $perPage), + 'financial-operations/installation-details' => $this->installationPage($currentPage, $perPage), 'financial-risk-control/process-statistics' => $this->shopInfoFlowPage($currentPage, $perPage), default => ['data' => [], 'total' => 0], }; @@ -168,6 +171,16 @@ class UpstreamBusinessInformation } } + private function installationPage(int $page, int $perPage): array + { + $mirror = $this->installationMirror ?? app(InstallationDetailMirrorRepository::class); + $payload = $mirror->paginate($page, $perPage, InstallationDetailMirrorRepository::requestFilters()); + if (! $payload['ready']) { + throw new ServiceUnavailableHttpException(null, '安装明细镜像尚未完成首次同步'); + } + return ['data' => $payload['data'], 'total' => $payload['total']]; + } + /** * 将已登记的经营分析副本库查询还原成不经过页面缓存的固定只读查询。 * @@ -249,7 +262,10 @@ SQL); $page, $perPage, $filters, - $this->storesWithAccountOwnership($this->mirror->allStores($storeFilters)), + $this->storesWithAccountOwnership(array_map( + fn (array $row): array => $this->mapStore($row), + $this->mirror->allStores($storeFilters), + )), ); } diff --git a/app/Http/Controllers/Business/FinanceLedger.php b/app/Http/Controllers/Business/FinanceLedger.php index aa008265..2769a3de 100644 --- a/app/Http/Controllers/Business/FinanceLedger.php +++ b/app/Http/Controllers/Business/FinanceLedger.php @@ -14,6 +14,7 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; use Illuminate\Validation\Rule; use Illuminate\Validation\ValidationException; +use Modules\EcommerceFinance\Services\FinanceLedgerAccountCreator; use Modules\EcommerceFinance\Services\FinanceLedgerAccountTemplate; use Modules\EcommerceFinance\Services\FinanceLedgerCalculator; use Modules\EcommerceFinance\Services\FinanceLedgerPayableMappingBulk; @@ -36,6 +37,81 @@ class FinanceLedger extends Controller return $template->download(); } + public function storeAccount(Request $request, FinanceLedgerAccountCreator $creator): JsonResponse + { + abort_unless( + FinanceLedgerAccess::canEditAccountStatus(user()), + 403, + '只有财务台账管理员可以新增公司或账户。', + ); + + $openingDate = (string) config('finance_ledger.opening_date', '2026-10-17'); + $data = $request->validate([ + 'company_name' => ['required', 'string', 'max:100'], + 'company_register_place' => ['nullable', 'string', 'max:200'], + 'company_business_type' => ['nullable', 'string', 'max:50'], + 'company_tax_type' => ['nullable', 'string', 'max:50'], + 'company_status' => ['nullable', 'string', 'max:20'], + 'company_basic_bank' => ['nullable', 'string', 'max:200'], + 'company_basic_account_last4' => ['nullable', 'string', 'max:50'], + 'company_remark' => ['nullable', 'string', 'max:500'], + 'account_short' => ['required', 'string', 'max:100'], + 'account_type' => ['nullable', 'string', 'max:50'], + 'status' => ['required', 'string', Rule::in(array_keys(FinanceLedgerAccountStatus::OPTIONS))], + 'region' => ['nullable', 'string', 'max:50'], + 'account_no' => ['nullable', 'string', 'max:50'], + 'bank_name' => ['nullable', 'string', 'max:200'], + 'bank_city_branch' => ['nullable', 'string', 'max:100'], + 'company_plan' => ['nullable', 'string', 'max:50'], + 'account_should_keep' => [ + 'nullable', + 'string', + Rule::in(FinanceLedgerAccountShouldKeep::OPTIONS), + ], + 'opening_date' => [ + 'required', + 'date_format:Y-m-d', + 'after_or_equal:' . $openingDate, + 'before_or_equal:' . now()->toDateString(), + ], + 'opening_balance' => ['nullable', 'numeric'], + ]); + + $accountType = trim((string) ($data['account_type'] ?? '')); + if ($accountType !== '') { + $connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')); + $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(), + ); + if (! in_array($accountType, $allowedTypes, true)) { + throw ValidationException::withMessages([ + 'account_type' => '请选择现有账户类型。', + ]); + } + } + + $result = $creator->create($data); + $connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')); + $message = match (true) { + $result['company_created'] => '已新增公司并建立首个账户。', + $result['company_updated'] => '已新增账户并更新公司资料。', + default => '已新增账户并关联现有公司。', + }; + + return response()->json([ + 'ok' => true, + 'message' => $message, + 'account' => $result, + 'total_accounts' => $connection->table('fin_accounts')->count(), + 'total_companies' => $connection->table('fin_companies')->count(), + ], 201, ['Cache-Control' => 'private, no-store']); + } + public function syncShopData(FinanceLedgerShopDataSynchronizer $synchronizer) { abort_unless(FinanceLedgerAccess::canEditAccountStatus(user()), 403, '只有财务台账管理员可以同步店铺余额和提现。'); diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index 0bda7d6b..8fab44ed 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -22,6 +22,7 @@ use App\Models\Business\CourierAgreementSignature; use App\Models\Business\CourierQuoteSignature; use App\Models\Business\CourierQuoteVersion; use App\Models\Business\CourierSigningSubject; +use App\Models\Business\InstallationDetail; use App\Models\Business\InventoryWarehouseNameMapping; use App\Models\Business\Todo; use App\Business\CourierSurchargeAuditLogRepository; @@ -55,7 +56,7 @@ use Modules\EcommerceFinance\Repositories\ShippingOrderRepository; use Modules\EcommerceFinance\Repositories\ThirdPartyWarehouseStorageMirrorRepository; use Modules\EcommerceFinance\Repositories\EdgeBandingRepository; use Modules\EcommerceFinance\Repositories\InventoryRepository; -use Modules\EcommerceFinance\Repositories\InstallationFeeSummaryRepository; +use Modules\EcommerceFinance\Repositories\InstallationDetailMirrorRepository; use Modules\EcommerceFinance\Repositories\InternalFlowReportRepository; use Modules\EcommerceFinance\Repositories\JdPromotionAccountBalanceRepository; use Modules\EcommerceFinance\Repositories\JdWalletBillRepository; @@ -880,7 +881,7 @@ class Pages extends Controller public function __construct( private readonly EdgeBandingRepository $edgeBanding, private readonly InventoryRepository $inventory, - private readonly InstallationFeeSummaryRepository $installationFees, + private readonly InstallationDetailMirrorRepository $installationDetails, private readonly NonJingxiRefundMirrorRepository $nonJingxiRefunds, private readonly SmallPaymentWorkOrderMirrorRepository $smallPaymentWorkOrders, private readonly ProductCatalogMirrorRepository $productCatalog, @@ -1085,6 +1086,39 @@ class Pages extends Controller )); } + public function installationDetails(string $guid) + { + $section = collect(SidebarPermissions::navigationFor(user()))->firstWhere('slug', 'financial-operations'); + abort_unless($section && collect($section['pages'] ?? [])->contains('slug', 'installation-details'), 403, '当前账号没有该页面权限。'); + $payload = $this->installationDetails->details($guid); + if (! $payload['ready']) { + return response()->json(['message' => '安装明细镜像尚未完成首次同步'], 503); + } + + return response()->json(['data' => $payload['data'], 'has_more' => $payload['has_more'] ?? false]); + } + + /** 安装明细页:按客服审核时间汇总安装公司的结算、收款明细金额。 */ + public function installationDetailsSummary(): \Illuminate\Http\JsonResponse + { + $section = collect(SidebarPermissions::navigationFor(user()))->firstWhere('slug', 'financial-operations'); + abort_unless($section && collect($section['pages'] ?? [])->contains('slug', 'installation-details'), 403, '当前账号没有该页面权限。'); + + $filters = InstallationDetailMirrorRepository::requestFilters(); + if (empty($filters['customer_review_from']) || empty($filters['customer_review_to'])) { + return response()->json(['message' => '请选择完整的客服审核起止日期'], 422); + } + $payload = $this->installationDetails->feeDetails($filters); + if (! $payload['ready']) { + return response()->json(['message' => '安装明细镜像尚未完成首次同步'], 503); + } + + return response()->json([ + 'total' => $payload['total'], + 'rows' => $payload['rows'], + ], 200, ['Cache-Control' => 'private, no-store']); + } + /** * 账单明细导出:按页面同一套筛选条件流式导出 CSV(全部筛选结果)。 */ @@ -1252,6 +1286,49 @@ class Pages extends Controller return 'billing-details-export:' . (int) user()->id . ':' . $token; } + public function exportInstallationDetails(Request $request): StreamedResponse + { + $section = collect(SidebarPermissions::navigationFor(user()))->firstWhere('slug', 'financial-operations'); + abort_unless($section && collect($section['pages'] ?? [])->contains('slug', 'installation-details'), 403, '当前账号没有该页面权限。'); + + $filters = InstallationDetailMirrorRepository::requestFilters(); + $firstPage = $this->installationDetails->paginate(1, 100, $filters); + abort_if(! $firstPage['ready'], 503, '安装明细镜像尚未完成首次同步'); + $labels = InstallationDetail::columnLabels(); + + return response()->streamDownload(function () use ($filters, $firstPage, $labels): void { + $csv = fopen('php://output', 'w'); + if ($csv === false) { + throw new \RuntimeException('无法创建安装明细导出流。'); + } + + fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF)); + fputcsv($csv, array_values($labels)); + + $page = 1; + $result = $firstPage; + do { + foreach ($result['data'] as $row) { + fputcsv($csv, array_map( + fn (string $name): string => $this->csvText($row[$name] ?? ''), + array_keys($labels), + )); + } + + $page++; + $hasMore = ($page - 1) * 100 < $result['total']; + if ($hasMore) { + $result = $this->installationDetails->paginate($page, 100, $filters); + } + } while ($hasMore); + + fclose($csv); + }, '安装明细_' . now()->format('Ymd_His') . '.csv', [ + 'Content-Type' => 'text/csv; charset=UTF-8', + 'Cache-Control' => 'private, no-store', + ]); + } + public function exportBillOffset(Request $request): StreamedResponse { $section = collect(SidebarPermissions::navigationFor(user()))->firstWhere('slug', 'order-data'); @@ -1508,9 +1585,6 @@ class Pages extends Controller 'difference_total' => round($differenceTotal, 2), 'per_page' => $limit, ]; - $installationFee = $this->installationFees->summary(substr($month, 0, 7)); - $stats['installation_fee_total'] = $installationFee['total']; - $stats['installation_fee_ready'] = $installationFee['ready']; // 筛选下拉候选:本月镜像里实际出现的平台与店铺(按 6 小时缓存,避免每次开页做去重扫描), // 并把当前已选值并入,保证筛选项的文字不会因为换月而消失。 @@ -1773,34 +1847,6 @@ class Pages extends Controller ]); } - /** 业绩明细「安装费金额」明细:按发货月份汇总店铺、安装公司和包安装分类。 */ - public function installationFeeMetricDetails(): \Illuminate\Http\JsonResponse - { - [, $month] = $this->receiptOrderMatchMonths(); - if ($month === null) { - return response()->json([ - 'label' => '安装费金额', - 'month' => null, - 'total' => 0, - 'rows' => [], - ], 200, ['Cache-Control' => 'private, no-store']); - } - - $payload = $this->installationFees->details(substr($month, 0, 7)); - if (! $payload['ready']) { - return response()->json([ - 'message' => '安装费镜像尚未完成首次同步,请稍后重试。', - ], 503, ['Cache-Control' => 'private, no-store']); - } - - return response()->json([ - 'label' => '安装费金额', - 'month' => $payload['month'], - 'total' => $payload['total'], - 'rows' => $payload['rows'], - ], 200, ['Cache-Control' => 'private, no-store']); - } - /** * 业绩明细当前筛选条件。指标详情与页面统计共用同一组请求参数。 * diff --git a/app/Http/Controllers/Business/SystemizationTodos.php b/app/Http/Controllers/Business/SystemizationTodos.php index 80d8b7fc..650b4c21 100644 --- a/app/Http/Controllers/Business/SystemizationTodos.php +++ b/app/Http/Controllers/Business/SystemizationTodos.php @@ -79,33 +79,45 @@ class SystemizationTodos extends Controller $items = $query ->select($query->qualifyColumn('*')) - ->selectRaw( - "ROW_NUMBER() OVER ( - PARTITION BY COALESCE(NULLIF(category, ''), '__uncategorized__') - ORDER BY - CASE WHEN status = '已完成' THEN 1 ELSE 0 END, - CASE WHEN section IS NULL OR section = '' THEN 1 ELSE 0 END, - section, - CASE WHEN due_date IS NULL THEN 1 ELSE 0 END, - due_date, - sort_order, - id - ) AS category_sequence" - ) - ->orderByRaw("CASE WHEN status = '已完成' THEN 1 ELSE 0 END") - ->orderByRaw("CASE WHEN category IS NULL OR category = '' THEN 1 ELSE 0 END") - ->orderBy('category') - ->orderByRaw("CASE WHEN section IS NULL OR section = '' THEN 1 ELSE 0 END") - ->orderBy('section') - ->orderByRaw('CASE WHEN due_date IS NULL THEN 1 ELSE 0 END') - ->orderBy('due_date') ->orderBy('sort_order') ->orderBy('id') ->get(); - if ($filters['sequence'] > 0) { + $sequenceMap = $this->hierarchySequenceMap(); + $items->each(function (SystemizationTodo $todo) use ($sequenceMap): void { + $sequence = $sequenceMap[$todo->id] ?? [ + 'type_sequence' => 0, + 'content_sequence' => 0, + 'item_sequence' => 0, + 'order' => PHP_INT_MAX, + 'category_display' => '', + 'section_display' => '', + ]; + $todo->setAttribute('type_sequence', $sequence['type_sequence']); + $todo->setAttribute('content_sequence', $sequence['content_sequence']); + $todo->setAttribute('item_sequence', $sequence['item_sequence']); + $todo->setAttribute('hierarchy_order', $sequence['order']); + $todo->setAttribute('category_display', $sequence['category_display']); + $todo->setAttribute('section_display', $sequence['section_display']); + }); + $items = $items->sortBy('hierarchy_order')->values(); + if ($filters['type_sequence'] > 0) { $items = $items ->filter(static fn (SystemizationTodo $todo): bool => - (int) $todo->category_sequence === $filters['sequence'] + (int) $todo->type_sequence === $filters['type_sequence'] + ) + ->values(); + } + if ($filters['content_sequence'] > 0) { + $items = $items + ->filter(static fn (SystemizationTodo $todo): bool => + (int) $todo->content_sequence === $filters['content_sequence'] + ) + ->values(); + } + if ($filters['item_sequence'] > 0) { + $items = $items + ->filter(static fn (SystemizationTodo $todo): bool => + (int) $todo->item_sequence === $filters['item_sequence'] ) ->values(); } @@ -172,6 +184,121 @@ class SystemizationTodos extends Controller ]; } + private function hierarchySequenceMap(): array + { + $todos = SystemizationTodo::query() + ->orderBy('sort_order') + ->orderBy('id') + ->get(['id', 'category', 'section', 'sort_order']); + $rows = $todos->map(function (SystemizationTodo $todo): array { + $category = $this->hierarchyLabel($todo->category); + $section = $this->hierarchyLabel($todo->section); + + return [ + 'category_key' => $category === '' ? "\0" : $category, + 'category_label' => $category, + 'section_key' => $section === '' ? "\0" : $section, + 'section_label' => $section, + 'sort_order' => (int) $todo->sort_order, + 'id' => (int) $todo->id, + ]; + })->all(); + + $categoryOrder = []; + $categoryLabels = []; + $sectionOrder = []; + $sectionLabels = []; + $groupedRows = []; + foreach ($rows as $row) { + $categoryKey = $row['category_key']; + $sectionKey = $row['section_key']; + $categoryOrder[$categoryKey] = min( + $categoryOrder[$categoryKey] ?? PHP_INT_MAX, + $row['sort_order'], + ); + $categoryLabels[$categoryKey] = $row['category_label']; + $sectionOrder[$categoryKey][$sectionKey] = min( + $sectionOrder[$categoryKey][$sectionKey] ?? PHP_INT_MAX, + $row['sort_order'], + ); + $sectionLabels[$categoryKey][$sectionKey] = $row['section_label']; + $groupedRows[$categoryKey][$sectionKey][] = $row; + } + + $categoryKeys = array_keys($categoryOrder); + usort($categoryKeys, function (string $left, string $right) use ( + $categoryOrder, + $categoryLabels, + ): int { + return $this->compareHierarchyGroups( + $left, + $right, + $categoryOrder, + $categoryLabels, + ); + }); + + $sequenceMap = []; + $hierarchyOrder = 0; + foreach ($categoryKeys as $categoryIndex => $categoryKey) { + $contentKeys = array_keys($sectionOrder[$categoryKey]); + usort($contentKeys, function (string $left, string $right) use ( + $categoryKey, + $sectionOrder, + $sectionLabels, + ): int { + return $this->compareHierarchyGroups( + $left, + $right, + $sectionOrder[$categoryKey], + $sectionLabels[$categoryKey], + ); + }); + + foreach ($contentKeys as $contentIndex => $contentKey) { + $contentRows = $groupedRows[$categoryKey][$contentKey]; + usort($contentRows, static fn (array $left, array $right): int => + [$left['sort_order'], $left['id']] <=> [$right['sort_order'], $right['id']] + ); + + foreach ($contentRows as $itemIndex => $row) { + $sequenceMap[$row['id']] = [ + 'type_sequence' => $categoryIndex + 1, + 'content_sequence' => $contentIndex + 1, + 'item_sequence' => $itemIndex + 1, + 'order' => $hierarchyOrder++, + 'category_display' => $row['category_label'], + 'section_display' => $row['section_label'], + ]; + } + } + } + + return $sequenceMap; + } + + private function compareHierarchyGroups( + string $left, + string $right, + array $sortOrders, + array $labels, + ): int { + $comparison = ($sortOrders[$left] ?? PHP_INT_MAX) <=> ($sortOrders[$right] ?? PHP_INT_MAX); + + return $comparison !== 0 + ? $comparison + : strnatcasecmp($labels[$left] ?? '', $labels[$right] ?? ''); + } + + private function hierarchyLabel(?string $value): string + { + return trim((string) preg_replace( + '/^\s*(?:\d+|[一二三四五六七八九十百]+)\s*[、..::)]\s*/u', + '', + (string) $value, + )); + } + public function store(Request $request): RedirectResponse { $data = $this->validated($request); @@ -280,7 +407,9 @@ class SystemizationTodos extends Controller 'assignees' => $this->peopleFilter('assignee'), 'start_date' => $this->normalizeDateFilter(request('start_date')), 'due_date' => $this->normalizeDateFilter(request('due_date')), - 'sequence' => max(0, (int) request('sequence', 0)), + 'type_sequence' => max(0, (int) request('type_sequence', 0)), + 'content_sequence' => max(0, (int) request('content_sequence', 0)), + 'item_sequence' => max(0, (int) request('item_sequence', 0)), 'keyword' => trim((string) request('keyword', '')), ]; } diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 907e47df..c669c6e3 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -160,6 +160,7 @@ class Kernel extends HttpKernel 'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class, 'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class, 'business.page' => \App\Http\Middleware\EnsureBusinessPageAccess::class, + 'business.page.viewer' => \App\Http\Middleware\EnsureBusinessPageViewerAccess::class, 'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class, 'can' => \Illuminate\Auth\Middleware\Authorize::class, 'cookies.encrypt' => \App\Http\Middleware\EncryptCookies::class, diff --git a/app/Http/Middleware/EnsureBusinessPageViewerAccess.php b/app/Http/Middleware/EnsureBusinessPageViewerAccess.php new file mode 100644 index 00000000..30f7c4c6 --- /dev/null +++ b/app/Http/Middleware/EnsureBusinessPageViewerAccess.php @@ -0,0 +1,29 @@ +user(); + $section = (string) $request->route('section'); + $page = (string) $request->route('page'); + + if ($page === '') { + return $next($request); + } + + abort_unless( + $user && SidebarPermissions::canViewPage($user, $section, $page), + 403, + '当前账号没有该页面权限。', + ); + + return $next($request); + } +} diff --git a/app/Models/Business/InstallationDetail.php b/app/Models/Business/InstallationDetail.php new file mode 100644 index 00000000..cc8edd0d --- /dev/null +++ b/app/Models/Business/InstallationDetail.php @@ -0,0 +1,78 @@ + 'integer', + 'item_quantity' => 'integer', + 'actual_install_quantity' => 'integer', + 'detail_requested_quantity' => 'integer', + 'requested_amount' => 'decimal:2', + 'settled_amount' => 'decimal:2', + 'actual_install_fee' => 'decimal:2', + 'payment_amount' => 'decimal:2', + 'detail_requested_amount' => 'decimal:2', + 'detail_settled_amount' => 'decimal:2', + 'detail_received_amount' => 'decimal:2', + ]; + + public static function columnLabels(): array + { + return [ + 'installation_no' => '安装单号', 'delete_status' => '删除状态', 'quote_status' => '报价状态', + 'quote_deadline' => '报价截至时间', 'installation_company' => '安装公司', 'submitted_at' => '提交时间', + 'shipped_at' => '发货时间', 'buyer_nickname' => '买家昵称', 'store_name' => '店铺名称', + 'installation_type' => '类型', 'pre_push_verification' => '推送前验证', 'push_status' => '推送状态', + 'push_result' => '推送结果', 'arranged_status' => '是否安排', 'order_no' => '订单编号', + 'item_quantity' => '商品数量', 'buyer_name' => '买家姓名', 'buyer_phone' => '买家号码', 'notes' => '备注', + 'province' => '省', 'city' => '市', 'district' => '区', 'address' => '地址', + 'requested_amount' => '申请总金额', 'settled_amount' => '结算总金额', 'image_urls' => '图片地址', + 'completed_status' => '是否完工', 'customer_service_review' => '客服审核', + 'customer_service_reviewed_at' => '客服审核时间', 'finance_review' => '财务审核', + 'finance_reviewed_at' => '财务审核时间', 'actual_install_quantity' => '实际安装数量', + 'actual_install_fee' => '实际安装费用', 'actual_install_product_name' => '实际安装商品名称', + 'installer_phone' => '安装师傅电话', 'installation_company_notes' => '安装公司备注', + 'installation_fee_payer' => '安装费承担方', 'payment_channel' => '收款渠道', + 'payment_transaction_no' => '收款交易单号', 'payment_voucher' => '收款凭证', 'payment_amount' => '收款金额', + 'creator' => '创建人', 'related_guid' => '关联guid', 'product_id' => '商品id', 'id_owner' => 'ID负责人', + 'department_owner' => '部门负责人', 'auxiliary_spu' => '辅助码Spu', 'product_store_code' => '商品店铺编码', + 'item_no' => '货号', 'barcode' => '条形码', 'product_name' => '商品名称', 'supplier_name' => '供应商名称', + 'detail_requested_amount' => '申请明细金额', 'detail_requested_quantity' => '申请明细数量', + 'detail_settled_amount' => '结算明细金额', 'detail_received_amount' => '收款明细金额', + 'change_reason' => '变更原因', 'source_created_at' => '创建时间', 'source_modified_at' => '修改时间', + 'product_name_1' => '商品名称(1)', + ]; + } + + public static function fieldTypes(): array + { + return [ + 'item_quantity' => 'number', 'actual_install_quantity' => 'number', 'detail_requested_quantity' => 'number', + 'requested_amount' => 'currency', 'settled_amount' => 'currency', 'actual_install_fee' => 'currency', + 'payment_amount' => 'currency', 'detail_requested_amount' => 'currency', + 'detail_settled_amount' => 'currency', 'detail_received_amount' => 'currency', + ]; + } +} diff --git a/app/Utilities/SidebarPermissions.php b/app/Utilities/SidebarPermissions.php index 7ac43875..fb0cd52c 100644 --- a/app/Utilities/SidebarPermissions.php +++ b/app/Utilities/SidebarPermissions.php @@ -104,7 +104,7 @@ class SidebarPermissions $allowed = self::allowedKeys($user); if ($allowed === null) { - return $navigation; + return self::applyPageViewerAllowlists($navigation, $user); } // 费用明细承接原「广告费用 / 营销费用」入口,并复用账单明细的数据权限。 @@ -285,7 +285,49 @@ class SidebarPermissions } } - return $navigation; + return self::applyPageViewerAllowlists($navigation, $user); + } + + public static function canViewPage(User $user, string $section, string $page): bool + { + $pageKey = trim($section, '/') . '/' . trim($page, '/'); + $allowedViewers = config('business_page_access.page_viewers')[$pageKey] ?? null; + + if (! is_array($allowedViewers)) { + return true; + } + + return in_array(trim((string) $user->name), $allowedViewers, true); + } + + private static function applyPageViewerAllowlists(array $navigation, User $user): array + { + if (empty(config('business_page_access.page_viewers', []))) { + return $navigation; + } + + return array_values(array_filter(array_map( + static function (array $section) use ($user): ?array { + $sectionSlug = (string) ($section['slug'] ?? ''); + $pages = $section['pages'] ?? []; + + if ($pages === []) { + return $section; + } + + $section['pages'] = array_values(array_filter( + $pages, + static fn (array $page): bool => self::canViewPage( + $user, + $sectionSlug, + (string) ($page['slug'] ?? ''), + ), + )); + + return $section['pages'] === [] ? null : $section; + }, + $navigation, + ))); } public static function allKeys(): array diff --git a/config/business_data.php b/config/business_data.php index f3008331..348db6c9 100644 --- a/config/business_data.php +++ b/config/business_data.php @@ -126,8 +126,8 @@ return [ 'jiansun.dbo.alibaba_bill' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '阿里巴巴账单'], 'jiansun.dbo.jd_spreadBills' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '京东推广账单'], 'jiansun.dbo.jd_costBills' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '京东钱包账单'], - 'jiansun.dbo.fys_Ticketing_install_view' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '业绩明细-安装费金额'], - 'jiansun.dbo.fys_Ticketing_install_item' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '业绩明细-安装费明细'], + 'jiansun.dbo.fys_Ticketing_install_view' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '安装明细'], + 'jiansun.dbo.fys_Ticketing_install_item' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '安装明细详情'], 'jiansun.dbo.finance_purchaseReturn' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '供应商退货对账'], 'jiansun.dbo.finance_invoiceDeduction' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '供应商发票扣款'], 'jiansun.dbo.fys_supplierPaymentSummary' => ['role' => 'serving_fact', 'target_store' => 'mariadb', 'consumer' => '供应商应付'], diff --git a/config/business_navigation.php b/config/business_navigation.php index 31bf1c44..b5ae5cb2 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -822,7 +822,7 @@ $navigation = [ 'directory' => [ 'icon' => 'receipt_long', 'status' => '共享镜像', - 'description' => '覆盖店铺收款账户账单各平台页签(总账备注=订单收入),按「店铺 + 订单号」匹配订单侧商品金额与订单实付金额;一行 = 一个店铺的一个订单,商品金额只挂一次。「商品金额」按「主单(拆单子单不参与)→ 原单行(merge_so_id 为空)→ 全部行」优先级取数,合单发货(原单与合并行共用同一线上单号、没有主单)时只取本单原单行,避免把合并的另一单算进来;并排除虚拟条码(X0000x:信誉订单 / 虚拟订单)明细行;「订单实付金额」取订单级实付(同一套优先级,末档兜底取该单号下最大值)再减去虚拟条码行实付。「优惠金额」「冲减金额」「违约赔付(冲减收入)」分别取对应总账备注的账单净额,不计入收入金额;「退款金额」只单独列示,不计入收入金额、也不参与差额;「种菜金额」是订单号在种菜明细表或聚水潭订单标签明细(是否种菜订单=是)任意一边出现过时,该订单的收入金额,只是收入金额里的一部分,不参与差额;差额=(收入金额+优惠金额+冲减金额+违约赔付(冲减收入))−订单实付金额,匹配状态与差额异常备注同样按订单实付金额判定,未匹配到订单实付金额的行按订单实付金额=0 计入差额,只反映真正对不上的部分。挂不到订单行的抵减项会补成一行(收入/商品留空)。「差额异常备注」只说明差额为什么产生,按顺序自动判定;同一平台、店铺、交易单号在相邻月份合并收入/优惠/冲减/违约赔付后与订单实付对平的,优先标「差额=跨月收款(合并已对平)」,未对平仍标「差额待核实」;其余规则均未命中且相关金额在 2 分钱内精确对平时,退款及其与抵减项的组合复用「订单实付含已退款部分」,优惠、冲减与违约赔付组合复用「订单实付未减优惠和冲减」;没有可复用的原备注时继续标「差额待核实」。都不满足时,若「卖家实收」或「应付/已付」能对上账单,差额按 0 计并在「匹配原因」列标注口径核对;其余标「差额待核实」。「匹配原因」单独一列,取值:账单无单号 / 账单无单号(采销收款)/ 单号格式异常 / 订单表无此单 / 1688采销收款(非销售单)/ 订单无实付金额 / 无收款账单(「采销收款」两档专指「支付宝-1688采销专用」账户:该账户收的是采销/供货结算款,不是店铺销售单收款,不按丢单处理)。安装费金额按所选月份的发货时间汇总安装明细的结算明细金额;收款渠道为空归为包安装,有值归为不包安装。', + 'description' => '覆盖店铺收款账户账单各平台页签(总账备注=订单收入),按「店铺 + 订单号」匹配订单侧商品金额与订单实付金额;一行 = 一个店铺的一个订单,商品金额只挂一次。「商品金额」按「主单(拆单子单不参与)→ 原单行(merge_so_id 为空)→ 全部行」优先级取数,合单发货(原单与合并行共用同一线上单号、没有主单)时只取本单原单行,避免把合并的另一单算进来;并排除虚拟条码(X0000x:信誉订单 / 虚拟订单)明细行;「订单实付金额」取订单级实付(同一套优先级,末档兜底取该单号下最大值)再减去虚拟条码行实付。「优惠金额」「冲减金额」「违约赔付(冲减收入)」分别取对应总账备注的账单净额,不计入收入金额;「退款金额」只单独列示,不计入收入金额、也不参与差额;「种菜金额」是订单号在种菜明细表或聚水潭订单标签明细(是否种菜订单=是)任意一边出现过时,该订单的收入金额,只是收入金额里的一部分,不参与差额;差额=(收入金额+优惠金额+冲减金额+违约赔付(冲减收入))−订单实付金额,匹配状态与差额异常备注同样按订单实付金额判定,未匹配到订单实付金额的行按订单实付金额=0 计入差额,只反映真正对不上的部分。挂不到订单行的抵减项会补成一行(收入/商品留空)。「差额异常备注」只说明差额为什么产生,按顺序自动判定;同一平台、店铺、交易单号在相邻月份合并收入/优惠/冲减/违约赔付后与订单实付对平的,优先标「差额=跨月收款(合并已对平)」,未对平仍标「差额待核实」;其余规则均未命中且相关金额在 2 分钱内精确对平时,退款及其与抵减项的组合复用「订单实付含已退款部分」,优惠、冲减与违约赔付组合复用「订单实付未减优惠和冲减」;没有可复用的原备注时继续标「差额待核实」。都不满足时,若「卖家实收」或「应付/已付」能对上账单,差额按 0 计并在「匹配原因」列标注口径核对;其余标「差额待核实」。「匹配原因」单独一列,取值:账单无单号 / 账单无单号(采销收款)/ 单号格式异常 / 订单表无此单 / 1688采销收款(非销售单)/ 订单无实付金额 / 无收款账单(「采销收款」两档专指「支付宝-1688采销专用」账户:该账户收的是采销/供货结算款,不是店铺销售单收款,不按丢单处理)。', 'empty' => '店铺收款匹配订单明细尚未同步,请先执行 store-receipt:order-match:sync。', 'fields' => [ ['name' => 'platform', 'label' => '平台'], @@ -870,6 +870,7 @@ $navigation = [ ], ], ], + ['slug' => 'installation-details', 'title' => '安装明细'], ['slug' => 'promotion-expenses', 'title' => '推广费用'], ['slug' => 'after-sales-expenses', 'title' => '售后费用'], ['slug' => 'small-payment-work-orders', 'title' => '小额打款'], diff --git a/config/business_page_access.php b/config/business_page_access.php new file mode 100644 index 00000000..eec1d370 --- /dev/null +++ b/config/business_page_access.php @@ -0,0 +1,19 @@ + [ + 'financial-operations/labor-expenses' => [ + '晨曦', + '江陵', + ], + ], +]; diff --git a/config/upstream_mirrors.php b/config/upstream_mirrors.php index 26b2fd1b..2da85ffc 100644 --- a/config/upstream_mirrors.php +++ b/config/upstream_mirrors.php @@ -675,7 +675,7 @@ return [ 'jiansun.dbo.alibaba_bill' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'alibaba_bill', 'target' => 'js_alibaba_bill', 'keys' => ['id']], 'jiansun.dbo.jd_spreadBills' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'jd_spreadBills', 'target' => 'js_jd_spread_bills', 'keys' => ['id'], 'watermark' => 'updateTime'], 'jiansun.dbo.jd_costBills' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'jd_costBills', 'target' => 'js_jd_cost_bills', 'keys' => ['id'], 'watermark' => 'creatTime'], - // 业绩明细「安装费金额」使用已整理的只读视图,避免在 HTTP 请求中临时 JOIN + // 安装明细页面使用已整理的只读视图,避免在 HTTP 请求中临时 JOIN // 安装主表和明细表;视图中的安装字段与 Excel 模板字段保持稳定别名。 'jiansun.dbo.fys_Ticketing_install_view' => [ 'source_key' => 'jiansun', diff --git a/docs/财务台账开发说明.md b/docs/财务台账开发说明.md index 0e6d6ed5..c5ac60c2 100644 --- a/docs/财务台账开发说明.md +++ b/docs/财务台账开发说明.md @@ -64,6 +64,7 @@ docker compose -f docker-compose.local.yml exec -u www-data app \ - 路由:`/workspace/financial-operations/ledger`(旧地址会自动跳转) - 页面:账户总表、店铺台账、每日资金流水、动账明细、账户映射、数据来源 - 账户总表支持导出空白 Excel 录入模板,包含页面列、状态和账户类型选项及填写说明 +- 账户总表编辑模式支持直接新增公司和首个账户:公司不存在时同步写入公司台账,可维护注册地、企业类型、税务类型、公司状态、基本户信息和备注;账户简称必须唯一,可同时登记期初日期与期初余额 - 写权限:账户映射、账户状态和账户类型修改仅系统管理员及配置的财务台账管理员角色;其他账号只读 - 调度:`finance-ledger:sync-rpt --days=7 --merge` 每天 02:30(Asia/Shanghai) diff --git a/modules/EcommerceFinance/Repositories/InstallationDetailMirrorRepository.php b/modules/EcommerceFinance/Repositories/InstallationDetailMirrorRepository.php new file mode 100644 index 00000000..2bba0cb2 --- /dev/null +++ b/modules/EcommerceFinance/Repositories/InstallationDetailMirrorRepository.php @@ -0,0 +1,356 @@ +>,total:int,ready:bool} */ + public function paginate(int $page, int $perPage, array $filters = []): array + { + $connection = $this->mirror->connection(); + $dataset = $this->datasets->get(self::DATASET); + $items = $this->datasets->get(self::ITEM_DATASET); + if (! $this->ready($dataset, $items, $connection->getName())) { + return ['data' => [], 'total' => 0, 'ready' => false]; + } + + $perPage = min(max($perPage, 1), 100); + // 主视图按商品行展开,同一安装单会出现多行;主表只保留每个 + // SourceGuid 的最新 Id,商品行通过 details() 单独展开。 + $latest = $connection->table($dataset->targetTable()) + ->selectRaw('MAX(Id) AS id') + ->groupBy('SourceGuid'); + $this->applyShipmentCutoff($latest); + $query = $connection->table($dataset->targetTable() . ' AS v') + ->joinSub($latest, 'latest', static fn ($join) => $join->on('latest.id', '=', 'v.Id')) + ->select('v.*'); + $this->applyFilters($query, $filters, $items->targetTable()); + $total = (clone $query)->count(); + $rows = $query->orderByDesc('v.Id')->forPage(max($page, 1), $perPage)->get(); + + return ['data' => $rows->map(fn (object $row): array => $this->map((array) $row))->all(), 'total' => $total, 'ready' => true]; + } + + /** @return array{data:list>,ready:bool} */ + public function details(string $guid): array + { + $dataset = $this->datasets->get(self::DATASET); + $items = $this->datasets->get(self::ITEM_DATASET); + $connection = $this->mirror->connection(); + if (! $this->ready($dataset, $items, $connection->getName())) { + return ['data' => [], 'ready' => false]; + } + + $mainExists = $connection->table($dataset->targetTable()) + ->where('SourceGuid', $guid); + $this->applyShipmentCutoff($mainExists); + $mainExists = $mainExists->exists(); + if (! $mainExists) { + return ['data' => [], 'ready' => true]; + } + + $transactionNo = $connection->table($dataset->targetTable()) + ->where('SourceGuid', $guid) + ->orderByDesc('Id')->value('OrderNum'); + $rows = $connection->table($items->targetTable()) + ->where('guid', $guid) + ->orderBy('Id') + ->limit(101) + ->get(); + + return ['data' => $rows->take(100)->map(fn (object $row): array => $this->mapItem((array) $row, $transactionNo))->all(), 'ready' => true, 'has_more' => $rows->count() > 100]; + } + + private function ready($dataset, $items, string $connection): bool + { + return $this->mirror->ready($dataset) && $this->mirror->ready($items) + && Schema::connection($connection)->hasTable($dataset->targetTable()) + && Schema::connection($connection)->hasTable($items->targetTable()); + } + + private function applyShipmentCutoff($query): void + { + $minimumShipTime = $this->minimumShipTime(); + if ($minimumShipTime !== null) { + $query->whereNotNull('ShipTIme')->where('ShipTIme', '>=', $minimumShipTime); + } + } + + private function minimumShipTime(): ?string + { + if (! $this->minimumShipTimeResolved) { + $this->resolvedMinimumShipTime = $this->datasets->minimumDatetime(self::DATASET)['value'] ?? null; + $this->minimumShipTimeResolved = true; + } + + return $this->resolvedMinimumShipTime; + } + + /** @return array{rows:int,requested:float,settled:float,paid:float} */ + public function summary(array $filters = []): array + { + $dataset = $this->datasets->get(self::DATASET); + $connection = $this->mirror->connection(); + if (! $this->mirror->ready($dataset) || ! Schema::connection($connection->getName())->hasTable($dataset->targetTable())) { + return ['rows' => 0, 'requested' => 0.0, 'settled' => 0.0, 'paid' => 0.0]; + } + $latest = $connection->table($dataset->targetTable()) + ->selectRaw('MAX(Id) AS id') + ->groupBy('SourceGuid'); + $this->applyShipmentCutoff($latest); + $query = $connection->table($dataset->targetTable() . ' AS v') + ->joinSub($latest, 'latest', static fn ($join) => $join->on('latest.id', '=', 'v.Id')); + $this->applyFilters($query, $filters, $this->datasets->get(self::ITEM_DATASET)->targetTable()); + $row = $query + ->selectRaw('COUNT(*) AS row_count, COALESCE(SUM(v.installcost),0) AS requested, COALESCE(SUM(v.settleAmount),0) AS settled, COALESCE(SUM(v.acceptAmount),0) AS paid') + ->first(); + return ['rows' => (int) $row->row_count, 'requested' => (float) $row->requested, 'settled' => (float) $row->settled, 'paid' => (float) $row->paid]; + } + + /** + * 按客服审核时间汇总安装费;金额取安装明细子表的结算明细金额。 + * + * @return array{ready:bool,total:float,rows:list} + */ + public function feeDetails(array $filters = []): array + { + $dataset = $this->datasets->get(self::DATASET); + $items = $this->datasets->get(self::ITEM_DATASET); + $connection = $this->mirror->connection(); + if (! $this->ready($dataset, $items, $connection->getName())) { + return ['ready' => false, 'total' => 0.0, 'rows' => []]; + } + + $from = trim((string) ($filters['customer_review_from'] ?? '')); + $to = trim((string) ($filters['customer_review_to'] ?? '')); + if ($from === '' || $to === '') { + return ['ready' => true, 'total' => 0.0, 'rows' => []]; + } + + $latest = $connection->table($dataset->targetTable()) + ->selectRaw('MAX(Id) AS id') + ->groupBy('SourceGuid'); + $main = $connection->table($dataset->targetTable() . ' AS v') + ->joinSub($latest, 'latest_install', static fn ($join) => $join->on('latest_install.id', '=', 'v.Id')) + ->select(['v.SourceGuid', 'v.shop_name', 'v.installCompanyName', 'v.costWay']); + $this->applyShipmentCutoff($main); + + $query = $connection->table($items->targetTable() . ' AS i') + ->joinSub($main, 'install_main', static fn ($join) => + $join->on('install_main.SourceGuid', '=', 'i.guid')) + ->whereNotNull('i.kfshTime') + ->where('i.kfshTime', '>=', $from) + ->where('i.kfshTime', '<', \Illuminate\Support\Carbon::parse($to)->addDay()->toDateString()); + + $platformSql = "CASE" + . " WHEN COALESCE(TRIM(install_main.shop_name), '') LIKE '%阿里巴巴%'" + . " OR COALESCE(TRIM(install_main.shop_name), '') LIKE '%天猫%'" + . " OR COALESCE(TRIM(install_main.shop_name), '') LIKE '%新佰广%'" + . " OR COALESCE(TRIM(install_main.shop_name), '') LIKE '%淘工厂%'" + . " OR COALESCE(TRIM(install_main.shop_name), '') LIKE '%淘宝%' THEN '淘系'" + . " WHEN COALESCE(TRIM(install_main.shop_name), '') LIKE '抖音-%' THEN '抖音'" + . " WHEN COALESCE(TRIM(install_main.shop_name), '') LIKE '拼多多-%' THEN '拼多多'" + . " WHEN COALESCE(TRIM(install_main.shop_name), '') LIKE '唯品会-%' THEN '唯品会'" + . " WHEN COALESCE(TRIM(install_main.shop_name), '') LIKE '京东-%' THEN '京东'" + . " WHEN COALESCE(TRIM(install_main.shop_name), '') LIKE '京喜-%' THEN '京喜'" + . " ELSE '其他' END"; + + $rows = $query + ->selectRaw($platformSql . ' AS platform') + ->selectRaw("COALESCE(NULLIF(TRIM(install_main.shop_name), ''), '未填写店铺') AS store_name") + ->selectRaw("COALESCE(NULLIF(TRIM(install_main.installCompanyName), ''), '未填写安装公司') AS installation_company") + ->selectRaw("CASE WHEN install_main.costWay IS NULL OR TRIM(install_main.costWay) = '' THEN '包安装' ELSE '不包安装' END AS payment_channel") + ->selectRaw('ROUND(SUM(CAST(i.settleItemAmount AS DECIMAL(18,2))), 2) AS amount') + ->groupByRaw( + $platformSql . ',' + . " COALESCE(NULLIF(TRIM(install_main.shop_name), ''), '未填写店铺')," + . " COALESCE(NULLIF(TRIM(install_main.installCompanyName), ''), '未填写安装公司')," + . " CASE WHEN install_main.costWay IS NULL OR TRIM(install_main.costWay) = '' THEN '包安装' ELSE '不包安装' END" + ) + ->havingRaw('ABS(SUM(CAST(i.settleItemAmount AS DECIMAL(18,2)))) > 0.004') + ->get() + ->map(static fn (object $row): array => [ + 'platform' => trim((string) ($row->platform ?? '其他')), + 'store_name' => trim((string) ($row->store_name ?? '')), + 'installation_company' => trim((string) ($row->installation_company ?? '')), + 'payment_channel' => (string) ($row->payment_channel ?? '包安装'), + 'amount' => round((float) ($row->amount ?? 0), 2), + ]) + ->sort(static fn (array $left, array $right): int => + [abs($right['amount']), $left['platform'], $left['store_name'], $left['installation_company']] + <=> [abs($left['amount']), $right['platform'], $right['store_name'], $right['installation_company']] + ) + ->values() + ->all(); + + return [ + 'ready' => true, + 'total' => round(array_sum(array_column($rows, 'amount')), 2), + 'rows' => $rows, + ]; + } + + public static function requestFilters(): array + { + $rules = []; + foreach (['created', 'customer_review', 'finance_review'] as $prefix) { + $rules[$prefix . '_from'] = ['nullable', 'date_format:Y-m-d']; + $rules[$prefix . '_to'] = ['nullable', 'date_format:Y-m-d']; + if (request()->filled($prefix . '_from')) $rules[$prefix . '_to'][] = 'after_or_equal:' . $prefix . '_from'; + } + foreach (['push_status','order_no','customer_review','finance_review','quote_status','arranged_status','fee_payer','completed_status','shop_name','delete_status','installation_company','installation_no','order_type','product_id'] as $key) { + $rules[$key] = ['nullable', 'string', 'max:191']; + } + foreach (['customer_review', 'finance_review', 'arranged_status', 'completed_status', 'delete_status'] as $key) $rules[$key][] = 'in:0,1'; + return validator(request()->only(array_keys($rules)), $rules)->validate(); + } + + private function applyFilters($query, array $filters, string $itemTable): void + { + foreach (['created'=>'SubmitTime', 'customer_review'=>'kfshTime', 'finance_review'=>'cwshTime'] as $prefix=>$column) { + if (!empty($filters[$prefix.'_from'])) $query->where('v.'.$column, '>=', $filters[$prefix.'_from']); + if (!empty($filters[$prefix.'_to'])) $query->where('v.'.$column, '<', \Illuminate\Support\Carbon::parse($filters[$prefix.'_to'])->addDay()->toDateString()); + } + foreach (['push_status'=>'apistatus','order_no'=>'OrderNum','quote_status'=>'BidStatus','fee_payer'=>'costtaker','shop_name'=>'shop_name','installation_company'=>'installCompanyName','installation_no'=>'ordernum_id','order_type'=>'TypeDto'] as $key=>$column) { + if (isset($filters[$key]) && $filters[$key] !== '') $query->where('v.'.$column, $filters[$key]); + } + foreach (['customer_review'=>'kfsh','finance_review'=>'cwsh','completed_status'=>'installComplete','delete_status'=>'Delete'] as $key=>$column) { + if (!isset($filters[$key]) || $filters[$key] === '') continue; + if ((string) $filters[$key] === '1') { + $query->where('v.'.$column, 1); + } else { + $query->where(function ($sub) use ($column): void { + $sub->whereNull('v.'.$column)->orWhere('v.'.$column, 0); + }); + } + } + if (isset($filters['arranged_status']) && $filters['arranged_status'] !== '') { + if ($filters['arranged_status'] === '1') { + $query->where('v.StatusId', '>', 0); + } else { + $query->where(function ($sub): void { + $sub->whereNull('v.StatusId')->orWhere('v.StatusId', '<=', 0); + }); + } + } + if (!empty($filters['product_id'])) { + $query->whereExists(function ($sub) use ($itemTable, $filters) { + $sub->selectRaw('1')->from($itemTable.' AS filter_item') + ->whereColumn('filter_item.guid', 'v.SourceGuid')->where('filter_item.shopproid', $filters['product_id']); + }); + } + } + + /** @param array $row */ + private function map(array $row): array + { + $value = static function (array $row, string ...$names): mixed { + foreach ($names as $name) { + foreach ($row as $key => $value) if (strcasecmp((string) $key, $name) === 0) return $value; + } + return null; + }; + + $mapped = [ + 'id' => $value($row, 'detail_id', 'Id'), 'installation_no' => $value($row, 'ordernum_id', 'OrderNum'), + 'delete_status' => $value($row, 'Delete'), 'quote_status' => $value($row, 'BidStatus'), 'quote_deadline' => $value($row, 'BidEndTime'), + 'installation_company' => $value($row, 'installCompanyName'), 'submitted_at' => $value($row, 'SubmitTime'), + 'shipped_at' => $value($row, 'ShipTIme'), 'buyer_nickname' => $value($row, 'BuyersNick'), + 'store_name' => $value($row, 'shop_name', 'shopname'), 'installation_type' => $value($row, 'TypeDto'), + 'pre_push_verification' => $value($row, 'checkMsg'), 'push_status' => $value($row, 'apistatus'), + 'push_result' => $value($row, 'apiresult'), 'arranged_status' => $this->arranged($value($row, 'StatusId')), + 'order_no' => $value($row, 'OrderNum'), + 'item_quantity' => $value($row, 'Num'), 'buyer_name' => $value($row, 'BuyersName'), 'buyer_phone' => $value($row, 'Mobile'), + 'notes' => $value($row, 'Nots'), 'province' => $value($row, 'Province'), 'city' => $value($row, 'City'), + 'district' => $value($row, 'Area'), 'address' => $value($row, 'Address'), 'requested_amount' => $value($row, 'installcost'), + 'settled_amount' => $value($row, 'settleAmount'), 'image_urls' => $value($row, 'Picture'), + 'completed_status' => $this->yesNo($value($row, 'installComplete')), + 'customer_service_review' => $this->review($value($row, 'kfsh')), 'customer_service_reviewed_at' => $value($row, 'kfshTime'), + 'finance_review' => $this->review($value($row, 'cwsh')), 'finance_reviewed_at' => $value($row, 'cwshTime'), + 'actual_install_quantity' => $value($row, 'installNum'), 'actual_install_fee' => $value($row, 'install_Cost'), + 'actual_install_product_name' => $value($row, 'installGoodsName'), 'installer_phone' => $value($row, 'installTel'), + 'installation_company_notes' => $value($row, 'installNote'), 'installation_fee_payer' => $value($row, 'costtaker'), + 'payment_channel' => $value($row, 'costWay'), 'payment_transaction_no' => $value($row, 'actporderNum'), + 'payment_voucher' => $value($row, 'costWayVoucher'), 'payment_amount' => $value($row, 'acceptAmount'), + 'creator' => $value($row, 'Operation'), 'related_guid' => $value($row, 'detail_guid', 'SourceGuid'), + 'product_id' => $value($row, 'shopproid'), 'auxiliary_spu' => $value($row, 'Spu'), + 'product_store_code' => $value($row, 'shopskucode'), 'item_no' => $value($row, 'proCode'), + 'barcode' => $value($row, 'barCode'), 'product_name' => $value($row, 'skuName'), 'supplier_name' => $value($row, 'scmname'), + 'detail_requested_amount' => $value($row, 'applyItemAmount'), 'detail_requested_quantity' => $value($row, 'installcount'), + 'detail_settled_amount' => $value($row, 'settleItemAmount'), 'detail_received_amount' => $value($row, 'acceptItemAmount'), + 'change_reason' => $value($row, 'remark'), 'source_created_at' => $value($row, 'item_created_at'), + 'source_modified_at' => $value($row, 'item_modified_at'), + ]; + // 页面按模板的完整 61 列渲染;视图暂未提供的字段必须显式为 null, + // 不能让 Blade 读取未定义属性。 + return array_replace(array_fill_keys(array_keys(\App\Models\Business\InstallationDetail::columnLabels()), null), $mapped); + } + + /** @param array $row */ + private function mapItem(array $row, mixed $transactionNo = null): array + { + $value = static function (array $row, string ...$names): mixed { + foreach ($names as $name) { + foreach ($row as $key => $value) { + if (strcasecmp((string) $key, $name) === 0) { + return $value; + } + } + } + return null; + }; + + return [ + 'transaction_no' => $value($row, 'Sourceno', 'source_no', 'orderNum', 'ordernum') ?? $transactionNo, + 'product_id' => $value($row, 'shopproid'), + 'id_owner' => $value($row, 'id_owner', 'idOwner'), + 'department_owner' => $value($row, 'department_owner', 'departmentOwner'), + 'auxiliary_spu' => $value($row, 'Spu'), + 'product_store_code' => $value($row, 'shopskucode'), + 'item_no' => $value($row, 'proCode'), + 'barcode' => $value($row, 'barCode'), + 'product_name' => $value($row, 'skuName'), + 'supplier_name' => $value($row, 'scmname'), + 'detail_requested_amount' => $value($row, 'applyItemAmount'), + 'detail_requested_quantity' => $value($row, 'installcount'), + 'detail_settled_amount' => $value($row, 'settleItemAmount'), + 'detail_received_amount' => $value($row, 'acceptItemAmount'), + 'change_reason' => $value($row, 'remark'), + 'source_created_at' => $value($row, 'createTime'), + 'source_modified_at' => $value($row, 'modifyTime'), + ]; + } + + private function yesNo(mixed $value): string + { + return (int) $value === 1 ? '是' : '否'; + } + + private function review(mixed $value): string + { + return (int) $value === 1 ? '已审核' : '未审核'; + } + + private function arranged(mixed $value): string + { + return (int) $value > 0 ? '已安排' : '未安排'; + } +} diff --git a/modules/EcommerceFinance/Repositories/InstallationFeeSummaryRepository.php b/modules/EcommerceFinance/Repositories/InstallationFeeSummaryRepository.php deleted file mode 100644 index a3e96dff..00000000 --- a/modules/EcommerceFinance/Repositories/InstallationFeeSummaryRepository.php +++ /dev/null @@ -1,150 +0,0 @@ -datasetsForPeriod($month); - if (! $this->ready($dataset, $items)) { - return ['ready' => false, 'month' => $month, 'total' => 0.0, 'row_count' => 0]; - } - - return Cache::remember( - 'installation-fee-summary:v1:' . $month, - now()->addMinutes(self::CACHE_MINUTES), - function () use ($dataset, $items, $month): array { - [$from, $to] = $this->monthRange($month); - $row = $this->baseQuery($dataset->targetTable(), $items->targetTable(), $from, $to) - ->selectRaw('COALESCE(SUM(CAST(i.settleItemAmount AS DECIMAL(18,2))), 0) AS total') - ->selectRaw('COUNT(*) AS row_count') - ->first(); - - return [ - 'ready' => true, - 'month' => $month, - 'total' => round((float) ($row->total ?? 0), 2), - 'row_count' => (int) ($row->row_count ?? 0), - ]; - }, - ); - } - - /** - * @return array{ready:bool,month:string,total:float,rows:list} - */ - public function details(string $month): array - { - [$dataset, $items] = $this->datasetsForPeriod($month); - if (! $this->ready($dataset, $items)) { - return ['ready' => false, 'month' => $month, 'total' => 0.0, 'rows' => []]; - } - - return Cache::remember( - 'installation-fee-details:v1:' . $month, - now()->addMinutes(self::CACHE_MINUTES), - function () use ($dataset, $items, $month): array { - [$from, $to] = $this->monthRange($month); - $rows = $this->baseQuery($dataset->targetTable(), $items->targetTable(), $from, $to) - ->selectRaw("COALESCE(NULLIF(TRIM(v.shop_name), ''), '未填写店铺') AS store_name") - ->selectRaw("COALESCE(NULLIF(TRIM(v.installCompanyName), ''), '未填写安装公司') AS installation_company") - ->selectRaw("CASE WHEN v.costWay IS NULL OR TRIM(v.costWay) = '' THEN '包安装' ELSE '不包安装' END AS payment_channel") - ->selectRaw('ROUND(SUM(CAST(i.settleItemAmount AS DECIMAL(18,2))), 2) AS amount') - ->groupByRaw( - "COALESCE(NULLIF(TRIM(v.shop_name), ''), '未填写店铺')," - . " COALESCE(NULLIF(TRIM(v.installCompanyName), ''), '未填写安装公司')," - . " CASE WHEN v.costWay IS NULL OR TRIM(v.costWay) = '' THEN '包安装' ELSE '不包安装' END" - ) - ->havingRaw('ABS(SUM(CAST(i.settleItemAmount AS DECIMAL(18,2)))) > 0.004') - ->get() - ->map(static fn (object $row): array => [ - 'store_name' => trim((string) ($row->store_name ?? '')), - 'installation_company' => trim((string) ($row->installation_company ?? '')), - 'payment_channel' => (string) ($row->payment_channel ?? '包安装'), - 'amount' => round((float) ($row->amount ?? 0), 2), - ]) - ->sort(static fn (array $left, array $right): int => - [abs($right['amount']), $left['store_name'], $left['installation_company'], $left['payment_channel']] - <=> [abs($left['amount']), $right['store_name'], $right['installation_company'], $right['payment_channel']] - ) - ->values() - ->all(); - - return [ - 'ready' => true, - 'month' => $month, - 'total' => round(array_sum(array_column($rows, 'amount')), 2), - 'rows' => $rows, - ]; - }, - ); - } - - /** @return array{0:\Modules\EcommerceFinance\Contracts\BusinessMirrorDataset,1:\Modules\EcommerceFinance\Contracts\BusinessMirrorDataset} */ - private function datasetsForPeriod(string $month): array - { - $this->monthRange($month); - - return [ - $this->datasets->get(self::MAIN_DATASET), - $this->datasets->get(self::ITEM_DATASET), - ]; - } - - /** @return array{0:string,1:string} */ - private function monthRange(string $month): array - { - $from = Carbon::createFromFormat('!Y-m', $month)->startOfMonth(); - if ($from === false || $from->format('Y-m') !== $month) { - throw new InvalidArgumentException('安装费月份格式无效。'); - } - - return [$from->toDateTimeString(), $from->copy()->addMonth()->toDateTimeString()]; - } - - private function ready($dataset, $items): bool - { - $connection = $this->mirror->connection(); - $schema = Schema::connection($connection->getName()); - - return $this->mirror->ready($dataset) - && $this->mirror->ready($items) - && $schema->hasTable($dataset->targetTable()) - && $schema->hasTable($items->targetTable()); - } - - private function baseQuery(string $mainTable, string $itemTable, string $from, string $to) - { - $connection = $this->mirror->connection(); - $latest = $connection->table($mainTable) - ->selectRaw('MAX(Id) AS id') - ->groupBy('SourceGuid'); - - return $connection->table($mainTable . ' AS v') - ->joinSub($latest, 'latest_install', static fn ($join) => $join->on('latest_install.id', '=', 'v.Id')) - ->join($itemTable . ' AS i', 'i.guid', '=', 'v.SourceGuid') - ->whereNotNull('v.ShipTIme') - ->where('v.ShipTIme', '>=', $from) - ->where('v.ShipTIme', '<', $to); - } -} diff --git a/modules/EcommerceFinance/Services/FinanceLedgerAccountCreator.php b/modules/EcommerceFinance/Services/FinanceLedgerAccountCreator.php new file mode 100644 index 00000000..84b0c368 --- /dev/null +++ b/modules/EcommerceFinance/Services/FinanceLedgerAccountCreator.php @@ -0,0 +1,166 @@ +text($data['company_name'] ?? ''); + $accountShort = $this->text($data['account_short'] ?? ''); + $accountNo = $this->text($data['account_no'] ?? ''); + $accountType = $this->text($data['account_type'] ?? ''); + $companyPlan = $this->text($data['company_plan'] ?? ''); + $companyFields = [ + 'register_place' => $this->nullableText($data['company_register_place'] ?? null), + 'business_type' => $this->nullableText($data['company_business_type'] ?? null), + 'tax_type' => $this->nullableText($data['company_tax_type'] ?? null), + 'status' => $this->nullableText($data['company_status'] ?? null), + 'basic_bank' => $this->nullableText($data['company_basic_bank'] ?? null), + 'basic_account_last4' => $this->nullableText($data['company_basic_account_last4'] ?? null), + 'remark' => $this->nullableText($data['company_remark'] ?? null), + ]; + $openingDate = (string) ($data['opening_date'] ?? now()->toDateString()); + $openingBalance = round((float) ($data['opening_balance'] ?? 0), 2); + $status = FinanceLedgerAccountStatus::normalize((string) ($data['status'] ?? '')); + $accountShouldKeep = FinanceLedgerAccountShouldKeep::normalize( + (string) ($data['account_should_keep'] ?? ''), + ); + $accountLast4 = FinanceLedgerAccountType::last4($accountType, $accountNo); + $normal = $status === FinanceLedgerAccountStatus::NORMAL; + $now = now(); + + return $connection->transaction(function () use ( + $connection, + $companyName, + $accountShort, + $accountNo, + $accountType, + $companyPlan, + $companyFields, + $openingDate, + $openingBalance, + $status, + $accountShouldKeep, + $accountLast4, + $normal, + $data, + $now, + ): array { + if ($connection->table('fin_accounts')->where('account_short', $accountShort)->exists()) { + throw ValidationException::withMessages([ + 'account_short' => '账户简称已存在,请改用其他简称或直接编辑现有账户。', + ]); + } + + $company = $connection->table('fin_companies')->where('name', $companyName)->first(['id']); + $companyCreated = $company === null; + $companyUpdated = false; + if ($companyCreated) { + $connection->table('fin_companies')->insert([ + 'name' => $companyName, + ...$companyFields, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } else { + $providedCompanyFields = array_filter( + $companyFields, + static fn (?string $value): bool => $value !== null, + ); + if ($providedCompanyFields !== []) { + $connection->table('fin_companies') + ->where('id', $company->id) + ->update([ + ...$providedCompanyFields, + 'updated_at' => $now, + ]); + $companyUpdated = true; + } + } + + $accountId = $connection->table('fin_accounts')->insertGetId([ + 'company_name' => $companyName, + 'account_short' => $accountShort, + 'region' => $this->nullableText($data['region'] ?? null), + 'account_no' => $accountNo !== '' ? $accountNo : null, + 'bank_name' => $this->nullableText($data['bank_name'] ?? null), + 'bank_city_branch' => $this->nullableText($data['bank_city_branch'] ?? null), + 'account_type' => $accountType !== '' ? $accountType : null, + 'company_plan' => $companyPlan !== '' ? $companyPlan : null, + 'account_should_keep' => $accountShouldKeep !== '' ? $accountShouldKeep : null, + 'account_last4' => $accountLast4, + 'current_balance' => $openingBalance, + 'blocked_amount' => $normal ? 0 : $openingBalance, + 'daily_available' => $normal ? $openingBalance : 0, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $connection->table('fin_account_opening')->insert([ + 'account_short' => $accountShort, + 'opening_date' => $openingDate, + 'opening_balance' => $openingBalance, + 'remark' => '新增账户期初', + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $connection->table('fin_daily_flows')->insert([ + 'flow_date' => $openingDate, + 'company_name' => $companyName, + 'account_short' => $accountShort, + 'opening_balance' => $openingBalance, + 'inflow' => 0, + 'outflow' => 0, + 'closing_balance' => $openingBalance, + 'remark' => '新增账户期初', + 'created_at' => $now, + 'updated_at' => $now, + ]); + + if (! $normal) { + $connection->table('fin_account_statuses')->insert([ + 'account_short' => $accountShort, + 'status' => $status, + 'updated_by' => (string) user()->name, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + return [ + 'id' => (int) $accountId, + 'company_name' => $companyName, + 'account_short' => $accountShort, + 'company_created' => $companyCreated, + 'company_updated' => $companyUpdated, + 'status' => $status, + 'opening_date' => $openingDate, + 'opening_balance' => $openingBalance, + ]; + }); + } + + private function text(mixed $value): string + { + return preg_replace('/\s+/u', ' ', trim((string) $value)) ?? trim((string) $value); + } + + private function nullableText(mixed $value): ?string + { + $text = $this->text($value); + + return $text !== '' ? $text : null; + } +} diff --git a/modules/EcommerceFinance/Services/FinanceLedgerCalculator.php b/modules/EcommerceFinance/Services/FinanceLedgerCalculator.php index a3838009..c540bd27 100644 --- a/modules/EcommerceFinance/Services/FinanceLedgerCalculator.php +++ b/modules/EcommerceFinance/Services/FinanceLedgerCalculator.php @@ -24,8 +24,8 @@ class FinanceLedgerCalculator $now = now(); $opening = $connection->table('fin_account_opening') - ->where('opening_date', '<=', $openingDate) ->orderBy('opening_date') + ->orderBy('id') ->get() ->groupBy('account_short') ->map(static fn ($rows) => $rows->last()); diff --git a/modules/EcommerceFinance/View/FinanceLedgerPageData.php b/modules/EcommerceFinance/View/FinanceLedgerPageData.php index fd44cef1..403cafce 100644 --- a/modules/EcommerceFinance/View/FinanceLedgerPageData.php +++ b/modules/EcommerceFinance/View/FinanceLedgerPageData.php @@ -64,6 +64,7 @@ class FinanceLedgerPageData 'ledgerTransactionAccountHeaderOptions' => $this->transactionAccountHeaderOptions(), 'ledgerTransactionDateHeaderOptions' => $this->transactionDateHeaderOptions(), 'ledgerAccountFilterOptions' => $this->accountFilterOptions(), + 'ledgerCompanyOptions' => $this->companyOptions(), 'ledgerAccountStatusOptions' => FinanceLedgerAccountStatus::OPTIONS, 'ledgerAccountPlanOptions' => $this->accountPlanOptions(), 'ledgerAccountKeepOptions' => FinanceLedgerAccountShouldKeep::OPTIONS, @@ -562,6 +563,25 @@ class FinanceLedgerPageData ->all(); } + private function companyOptions(): array + { + return $this->db()->table('fin_companies') + ->whereNotNull('name') + ->pluck('name') + ->merge( + $this->db()->table('fin_accounts') + ->whereNotNull('company_name') + ->distinct() + ->pluck('company_name'), + ) + ->map(static fn (mixed $value): string => trim((string) $value)) + ->filter() + ->unique() + ->sortBy(static fn (string $value): string => mb_strtolower($value)) + ->values() + ->all(); + } + private function applyLedgerHeaderFilter(Builder $query, string $tab, array $columns): void { $column = trim((string) request($tab . '_filter_column', '')); diff --git a/resources/views/business/pages/financial-ledger/ledger.blade.php b/resources/views/business/pages/financial-ledger/ledger.blade.php index 6bbf39ab..62c8603a 100644 --- a/resources/views/business/pages/financial-ledger/ledger.blade.php +++ b/resources/views/business/pages/financial-ledger/ledger.blade.php @@ -10,6 +10,7 @@ $accountFilterOptions = $ledgerAccountFilterOptions ?? ['regions' => [], 'types' => []]; $accountTypeOptions = $accountFilterOptions['types'] ?? []; $accountPlanOptions = $ledgerAccountPlanOptions ?? []; + $companyOptions = $ledgerCompanyOptions ?? []; $accountKeepOptions = $ledgerAccountKeepOptions ?? \App\Utilities\FinanceLedgerAccountShouldKeep::OPTIONS; $activeAccountFilterCount = count(array_filter([ request('accounts_q'), @@ -200,6 +201,35 @@ .ledger-edit-switch input:focus-visible + .ledger-edit-switch__track { outline:2px solid var(--vip-content-accent-dark, #856632); outline-offset:2px; } .ledger-edit-switch:has(input:checked) { border-color:var(--vip-content-accent-dark, #856632); background:var(--vip-content-accent-soft, #F7F0DE); color:var(--vip-content-accent-dark, #856632); } .ledger-edit-switch.is-disabled { cursor:not-allowed; opacity:.55; } + .ledger-add-company[hidden] { display:none !important; } + .ledger-company-dialog { width:min(54rem, calc(100vw - 2rem)); max-height:calc(100vh - 2rem); padding:0; overflow:auto; border:1px solid var(--vip-border, #D8D2C7); border-radius:.75rem; background:#fff; color:var(--vip-content-ink, #171717); box-shadow:0 8px 24px rgba(23,23,23,.18); } + .ledger-company-dialog::backdrop { background:rgba(5,5,6,.46); } + .ledger-company-form { margin:0; } + .ledger-company-form__header { display:flex; align-items:flex-start; justify-content:space-between; gap:1rem; padding:1rem 1.1rem .85rem; border-bottom:1px solid #e8e3da; } + .ledger-company-form__title { margin:0 0 .22rem; font-size:.92rem; line-height:1.35; } + .ledger-company-form__help { margin:0; color:var(--vip-content-muted, #595959); font-size:.7rem; line-height:1.55; } + .ledger-company-form__close { display:inline-grid; flex:0 0 auto; place-items:center; width:1.8rem; height:1.8rem; padding:0; border:1px solid transparent; border-radius:.35rem; background:transparent; color:var(--vip-content-muted, #595959); cursor:pointer; } + .ledger-company-form__close:hover { border-color:var(--vip-border, #D8D2C7); color:var(--vip-content-accent-dark, #856632); } + .ledger-company-form__body { display:grid; grid-template-columns:repeat(3, minmax(0, 1fr)); gap:.8rem; padding:1rem 1.1rem; } + .ledger-company-form__section { grid-column:1 / -1; display:flex; align-items:baseline; gap:.55rem; padding-top:.75rem; border-top:1px solid #e8e3da; color:var(--vip-content-ink, #171717); font-size:.76rem; font-weight:700; } + .ledger-company-form__section:first-child { padding-top:0; border-top:0; } + .ledger-company-form__section span:last-child { color:var(--vip-content-muted, #595959); font-size:.65rem; font-weight:400; } + .ledger-company-field { display:grid; min-width:0; gap:.3rem; } + .ledger-company-field--wide { grid-column:span 2; } + .ledger-company-field--full { grid-column:1 / -1; } + .ledger-company-field label { color:var(--vip-content-muted, #595959); font-size:.7rem; font-weight:600; } + .ledger-company-field input, + .ledger-company-field select, + .ledger-company-field textarea { width:100%; min-width:0; min-height:2.15rem; padding:0 .58rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.45rem; background:#fff; color:var(--vip-content-ink, #171717); font:inherit; } + .ledger-company-field textarea { min-height:3.4rem; padding-top:.45rem; padding-bottom:.45rem; resize:vertical; } + .ledger-company-field input:focus, + .ledger-company-field select:focus, + .ledger-company-field textarea:focus { border-color:var(--vip-content-accent-dark, #856632); outline:none; box-shadow:0 0 0 3px rgba(133,102,50,.16); } + .ledger-company-field__hint { color:var(--vip-content-muted, #595959); font-size:.65rem; line-height:1.45; } + .ledger-company-form__message { grid-column:1 / -1; margin:0; padding:.55rem .65rem; border:1px solid rgba(184,0,0,.28); border-radius:.4rem; background:#fff5f5; color:var(--vip-danger, #B80000); font-size:.7rem; line-height:1.5; } + .ledger-company-form__message[hidden] { display:none !important; } + .ledger-company-form__footer { display:flex; align-items:center; justify-content:flex-end; gap:.5rem; padding:.8rem 1.1rem 1rem; border-top:1px solid #e8e3da; } + .ledger-company-form__footer .ledger-btn:disabled { opacity:.58; cursor:wait; } #ledger-panel-accounts .ledger-account-editor { display:none; } #ledger-panel-accounts.is-account-edit-mode .ledger-account-readonly { display:none; } #ledger-panel-accounts.is-account-edit-mode .ledger-account-editor { display:block; } @@ -384,6 +414,8 @@ .ledger-source-tag { display:inline-flex; align-items:center; padding:.1rem .4rem; border-radius:.25rem; background:#f4f1eb; color:var(--vip-content-muted, #595959); font-size:.68rem; } .ledger-mono { font-variant-numeric:tabular-nums; } @media (max-width: 900px) { .ledger-hero { align-items:flex-start; flex-direction:column; } .ledger-field { min-width:calc(50% - .5rem); } } + @media (max-width: 760px) { .ledger-company-form__body { grid-template-columns:repeat(2, minmax(0, 1fr)); } .ledger-company-field--wide { grid-column:span 2; } } + @media (max-width: 520px) { .ledger-company-form__body { grid-template-columns:minmax(0, 1fr); } .ledger-company-field--wide { grid-column:auto; } }
@@ -425,11 +457,17 @@ @if ($activeAccountFilterCount > 0) 筛选中{{ $activeAccountFilterCount }} @endif -