Merge remote-tracking branch 'origin/main' into fix/chenxi-product-cost-bundle-residual-parents

This commit is contained in:
chenxi 2026-10-26 14:57:20 +08:00
commit 7582b9f4c4
35 changed files with 3076 additions and 730 deletions

View File

@ -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);
}

View File

@ -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),
)),
);
}

View File

@ -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, '只有财务台账管理员可以同步店铺余额和提现。');

View File

@ -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']);
}
/**
* 业绩明细当前筛选条件。指标详情与页面统计共用同一组请求参数。
*

View File

@ -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', '')),
];
}

View File

@ -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,

View File

@ -0,0 +1,29 @@
<?php
namespace App\Http\Middleware;
use App\Utilities\SidebarPermissions;
use Closure;
use Illuminate\Http\Request;
class EnsureBusinessPageViewerAccess
{
public function handle(Request $request, Closure $next): mixed
{
$user = $request->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);
}
}

View File

@ -0,0 +1,78 @@
<?php
namespace App\Models\Business;
use App\Abstracts\Model;
class InstallationDetail extends Model
{
protected $table = 'business_installation_details';
protected $fillable = [
'company_id', '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', 'product_id', 'id_owner',
'department_owner', 'auxiliary_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',
];
protected $casts = [
'company_id' => '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',
];
}
}

View File

@ -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

View File

@ -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' => '供应商应付'],

View File

@ -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' => '小额打款'],

View File

@ -0,0 +1,19 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Page-specific viewer allowlists
|--------------------------------------------------------------------------
|
| These rules apply after the regular sidebar permissions, including to
| super administrators. Use the page key in "section/page" form.
|
*/
'page_viewers' => [
'financial-operations/labor-expenses' => [
'晨曦',
'江陵',
],
],
];

View File

@ -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',

View File

@ -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)

View File

@ -0,0 +1,356 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
/** 只读安装明细镜像;页面不得回退直连 JianSun。 */
class InstallationDetailMirrorRepository
{
public const DATASET = 'jiansun.dbo.fys_Ticketing_install_view';
public const ITEM_DATASET = 'jiansun.dbo.fys_Ticketing_install_item';
private bool $minimumShipTimeResolved = false;
private ?string $resolvedMinimumShipTime = null;
public function __construct(
private readonly GenericBusinessMirrorRepository $mirror,
private readonly BusinessMirrorDatasetRegistry $datasets,
)
{
}
/** @return array{data:list<array<string,mixed>>,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<array<string,mixed>>,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<array{platform:string,store_name:string,installation_company:string,payment_channel:string,amount:float}>}
*/
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<string,mixed> $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<string,mixed> $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 ? '已安排' : '未安排';
}
}

View File

@ -1,150 +0,0 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Schema;
use InvalidArgumentException;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
/** 业绩明细「安装费金额」:按发货月份汇总安装结算明细。 */
class InstallationFeeSummaryRepository
{
public const MAIN_DATASET = 'jiansun.dbo.fys_Ticketing_install_view';
public const ITEM_DATASET = 'jiansun.dbo.fys_Ticketing_install_item';
private const CACHE_MINUTES = 5;
public function __construct(
private readonly GenericBusinessMirrorRepository $mirror,
private readonly BusinessMirrorDatasetRegistry $datasets,
) {
}
/** @return array{ready:bool,month:string,total:float,row_count:int} */
public function summary(string $month): array
{
[$dataset, $items] = $this->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<array{store_name:string,installation_company:string,payment_channel:string,amount:float}>}
*/
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);
}
}

View File

@ -0,0 +1,166 @@
<?php
namespace Modules\EcommerceFinance\Services;
use App\Utilities\FinanceLedgerAccountStatus;
use App\Utilities\FinanceLedgerAccountShouldKeep;
use App\Utilities\FinanceLedgerAccountType;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
/**
* 在账户总表中新增一个财务台账账户,并在公司不存在时同步建立公司台账。
*/
class FinanceLedgerAccountCreator
{
public function create(array $data): array
{
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
$companyName = $this->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;
}
}

View File

@ -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());

View File

@ -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', ''));

View File

@ -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; } }
</style>
<div class="ledger-hero">
@ -425,11 +457,17 @@
@if ($activeAccountFilterCount > 0)
<span class="ledger-filter-state" data-account-filter-state role="status" aria-live="polite" title="当前有 {{ $activeAccountFilterCount }} 个筛选条件生效"><span class="material-icons-outlined" aria-hidden="true">filter_alt</span><span>筛选中</span><b>{{ $activeAccountFilterCount }}</b></span>
@endif
<label class="ledger-edit-switch {{ $canEditAccounts ? '' : 'is-disabled' }}" title="{{ $canEditAccounts ? '开启后可修改账户类型和账户状态' : '当前账号没有财务台账编辑权限' }}">
<label class="ledger-edit-switch {{ $canEditAccounts ? '' : 'is-disabled' }}" title="{{ $canEditAccounts ? '开启后可修改账户信息并新增公司/账户' : '当前账号没有财务台账编辑权限' }}">
<span>编辑模式</span>
<input type="checkbox" role="switch" aria-label="编辑模式" aria-checked="false" data-ledger-edit-mode-toggle @disabled(! $canEditAccounts)>
<span class="ledger-edit-switch__track" aria-hidden="true"><span class="ledger-edit-switch__thumb"></span></span>
</label>
@if ($canEditAccounts)
<button class="ledger-btn ledger-add-company" type="button" data-ledger-add-company hidden>
<span class="material-icons-outlined" aria-hidden="true">domain_add</span>
新增公司/账户
</button>
@endif
<div class="ledger-column-picker" data-ledger-column-picker data-column-storage-key="caiwu.financial-ledger.accounts.preferences.v2.{{ auth()->id() }}.{{ company_id() }}">
<button class="ledger-btn" type="button" data-ledger-column-toggle aria-expanded="false"><span class="material-icons-outlined" aria-hidden="true">view_column</span>列设置</button>
<div class="ledger-column-panel" data-ledger-column-panel role="group" aria-label="选择账户总表展示列" hidden>
@ -441,6 +479,141 @@
<div class="ledger-actions"><button class="ledger-btn primary" type="submit"><span class="material-icons-outlined">search</span>查询</button><a class="ledger-btn" href="{{ $tabUrl('accounts') }}"><span class="material-icons-outlined">refresh</span>重置</a><a class="ledger-btn" href="{{ route('business.financial-ledger.accounts.template') }}" download data-account-template-download><span class="material-icons-outlined">download</span>导出录入模板</a></div>
<div class="ledger-download-status" data-account-template-download-status role="status" aria-live="polite" hidden>下载已开始,请查看浏览器下载目录。</div>
</form>
@if ($canEditAccounts)
<dialog class="ledger-company-dialog" data-ledger-company-dialog data-ledger-accounts-url="{{ $tabUrl('accounts') }}" aria-labelledby="ledger-company-dialog-title">
<form class="ledger-company-form" method="POST" action="{{ route('business.financial-ledger.accounts.store') }}" data-ledger-company-form>
@csrf
<header class="ledger-company-form__header">
<div>
<h3 class="ledger-company-form__title" id="ledger-company-dialog-title">新增公司/账户</h3>
<p class="ledger-company-form__help">公司不存在时自动加入公司台账,也可补充已有公司的资料。账户简称必须唯一,期初余额会作为该账户的起始资金并进入每日流水。</p>
</div>
<button class="ledger-company-form__close" type="button" aria-label="关闭新增公司/账户" data-ledger-company-close>
<span class="material-icons-outlined" aria-hidden="true">close</span>
</button>
</header>
<div class="ledger-company-form__body">
<div class="ledger-company-form__section">
<span>公司资料</span>
<span>除公司名称外均可留空;已有公司只会更新本次填写的字段。</span>
</div>
<div class="ledger-company-field">
<label for="ledger-company-name">公司名称 <span aria-hidden="true">*</span></label>
<input id="ledger-company-name" name="company_name" type="text" list="ledger-company-options" maxlength="100" required autocomplete="off" placeholder="选择或输入公司名称" data-ledger-company-first-field>
<span class="ledger-company-field__hint">输入公司台账中不存在的名称时会自动新增公司。</span>
</div>
<div class="ledger-company-field">
<label for="ledger-company-register-place">注册地</label>
<input id="ledger-company-register-place" name="company_register_place" type="text" maxlength="200" autocomplete="off" placeholder="如 上海市浦东新区">
</div>
<div class="ledger-company-field">
<label for="ledger-company-business-type">企业类型</label>
<input id="ledger-company-business-type" name="company_business_type" type="text" maxlength="50" autocomplete="off" placeholder="如 有限责任公司">
</div>
<div class="ledger-company-field">
<label for="ledger-company-tax-type">税务类型</label>
<input id="ledger-company-tax-type" name="company_tax_type" type="text" maxlength="50" autocomplete="off" placeholder="如 一般纳税人">
</div>
<div class="ledger-company-field">
<label for="ledger-company-company-status">公司状态</label>
<input id="ledger-company-company-status" name="company_status" type="text" maxlength="20" autocomplete="off" placeholder="如 正常、注销">
</div>
<div class="ledger-company-field">
<label for="ledger-company-basic-last4">基本户后4位</label>
<input id="ledger-company-basic-last4" name="company_basic_account_last4" type="text" maxlength="50" autocomplete="off" placeholder="如 1234">
</div>
<div class="ledger-company-field ledger-company-field--full">
<label for="ledger-company-basic-bank">基本户开户行</label>
<input id="ledger-company-basic-bank" name="company_basic_bank" type="text" maxlength="200" autocomplete="off" placeholder="公司基本户开户行">
</div>
<div class="ledger-company-field ledger-company-field--full">
<label for="ledger-company-remark">公司备注</label>
<textarea id="ledger-company-remark" name="company_remark" rows="2" maxlength="500" placeholder="公司层面的补充说明"></textarea>
</div>
<div class="ledger-company-form__section">
<span>账户资料</span>
<span>首个账户会自动关联上方公司。</span>
</div>
<div class="ledger-company-field">
<label for="ledger-company-account-short">账户简称 <span aria-hidden="true">*</span></label>
<input id="ledger-company-account-short" name="account_short" type="text" maxlength="100" required autocomplete="off" placeholder="如 上海甲公司农行">
</div>
<div class="ledger-company-field">
<label for="ledger-company-account-type">账户类型</label>
<select id="ledger-company-account-type" name="account_type">
<option value="">未填写</option>
@foreach ($accountTypeOptions as $accountType)
<option value="{{ $accountType }}">{{ $accountType }}</option>
@endforeach
</select>
</div>
<div class="ledger-company-field">
<label for="ledger-company-status">账户状态</label>
<select id="ledger-company-status" name="status" required>
@foreach ($accountStatusOptions as $statusCode => $statusLabel)
<option value="{{ $statusCode }}" @selected($statusCode === \App\Utilities\FinanceLedgerAccountStatus::NORMAL)>{{ $statusLabel }}</option>
@endforeach
</select>
</div>
<div class="ledger-company-field">
<label for="ledger-company-region">地区</label>
<input id="ledger-company-region" name="region" type="text" maxlength="50" autocomplete="off" placeholder="如 上海">
</div>
<div class="ledger-company-field">
<label for="ledger-company-account-no">账号</label>
<input id="ledger-company-account-no" name="account_no" type="text" maxlength="50" autocomplete="off" placeholder="银行账号或支付宝账号">
</div>
<div class="ledger-company-field">
<label for="ledger-company-bank-name">开户行</label>
<input id="ledger-company-bank-name" name="bank_name" type="text" maxlength="200" autocomplete="off" placeholder="开户银行">
</div>
<div class="ledger-company-field">
<label for="ledger-company-bank-branch">开户城市/网点</label>
<input id="ledger-company-bank-branch" name="bank_city_branch" type="text" maxlength="100" autocomplete="off" placeholder="城市及网点">
</div>
<div class="ledger-company-field">
<label for="ledger-company-plan">公司规划</label>
<input id="ledger-company-plan" name="company_plan" type="text" list="ledger-company-plan-options" maxlength="50" autocomplete="off" placeholder="如 保留、待注销">
</div>
<div class="ledger-company-field">
<label for="ledger-company-keep">账户是否保留</label>
<select id="ledger-company-keep" name="account_should_keep">
<option value="">未填写</option>
@foreach ($accountKeepOptions as $keepOption)
<option value="{{ $keepOption }}">{{ $keepOption }}</option>
@endforeach
</select>
</div>
<div class="ledger-company-field">
<label for="ledger-company-opening-date">期初日期</label>
<input id="ledger-company-opening-date" name="opening_date" type="date" min="{{ $ledgerOpeningDate }}" max="{{ now()->format('Y-m-d') }}" value="{{ now()->format('Y-m-d') }}" required>
</div>
<div class="ledger-company-field">
<label for="ledger-company-opening-balance">期初余额(元)</label>
<input id="ledger-company-opening-balance" name="opening_balance" type="number" inputmode="decimal" step="0.01" value="0" placeholder="0.00">
</div>
<p class="ledger-company-form__message" role="alert" data-ledger-company-error hidden></p>
</div>
<footer class="ledger-company-form__footer">
<button class="ledger-btn" type="button" data-ledger-company-cancel>取消</button>
<button class="ledger-btn primary" type="submit" data-ledger-company-save>
<span class="material-icons-outlined" aria-hidden="true">check</span>
保存公司及账户
</button>
</footer>
</form>
<datalist id="ledger-company-options">
@foreach ($companyOptions as $companyOption)
<option value="{{ $companyOption }}"></option>
@endforeach
</datalist>
<datalist id="ledger-company-plan-options">
@foreach ($accountPlanOptions as $planOption)
<option value="{{ $planOption }}"></option>
@endforeach
</datalist>
</dialog>
@endif
<div class="ledger-summary">
<div class="ledger-summary__item"><span>账户数</span><b>{{ number_format((int) ($ledgerAccountSummary['count'] ?? 0)) }}</b></div>
<a class="ledger-summary__item ledger-summary__item--link" href="{{ route('business.financial-ledger.unretained-accounts') }}" aria-label="查看不保留账户处理待办"><span>不保留账户数</span><b>{{ number_format((int) ($ledgerAccountSummary['unretained'] ?? 0)) }}</b></a>
@ -1322,6 +1495,11 @@
const accountPanel = page.querySelector('#ledger-panel-accounts');
const accountEditToggle = page.querySelector('[data-ledger-edit-mode-toggle]');
const accountAddCompanyButton = page.querySelector('[data-ledger-add-company]');
const accountCompanyDialog = page.querySelector('[data-ledger-company-dialog]');
const accountCompanyForm = page.querySelector('[data-ledger-company-form]');
const accountCompanyError = page.querySelector('[data-ledger-company-error]');
const accountCompanySave = page.querySelector('[data-ledger-company-save]');
const applyAccountEditMode = (enabled) => {
const active = Boolean(enabled && accountEditToggle && !accountEditToggle.disabled);
accountPanel?.classList.toggle('is-account-edit-mode', active);
@ -1329,10 +1507,78 @@
editor.disabled = !active;
});
accountEditToggle?.setAttribute('aria-checked', active ? 'true' : 'false');
if (accountAddCompanyButton) {
accountAddCompanyButton.hidden = !active;
}
};
accountEditToggle?.addEventListener('change', () => {
applyAccountEditMode(accountEditToggle.checked);
notify(accountEditToggle.checked ? '编辑模式已开启' : '编辑模式已关闭');
notify(accountEditToggle.checked ? '编辑模式已开启,可修改账户并新增公司/账户' : '编辑模式已关闭');
});
const closeAccountCompanyDialog = () => {
if (!accountCompanyDialog?.open) return;
accountCompanyDialog.close();
};
accountAddCompanyButton?.addEventListener('click', () => {
if (!accountCompanyDialog) return;
accountCompanyForm?.reset();
if (accountCompanyError) {
accountCompanyError.hidden = true;
accountCompanyError.textContent = '';
}
if (typeof accountCompanyDialog.showModal === 'function') {
accountCompanyDialog.showModal();
} else {
accountCompanyDialog.setAttribute('open', '');
}
page.querySelector('[data-ledger-company-first-field]')?.focus();
});
page.querySelector('[data-ledger-company-close]')?.addEventListener('click', closeAccountCompanyDialog);
page.querySelector('[data-ledger-company-cancel]')?.addEventListener('click', closeAccountCompanyDialog);
accountCompanyForm?.addEventListener('submit', async (event) => {
event.preventDefault();
if (!accountCompanyForm.reportValidity()) return;
if (accountCompanySave) accountCompanySave.disabled = true;
accountCompanyForm.setAttribute('aria-busy', 'true');
if (accountCompanyError) {
accountCompanyError.hidden = true;
accountCompanyError.textContent = '';
}
try {
const response = await fetch(accountCompanyForm.action, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
body: new FormData(accountCompanyForm),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const firstError = Object.values(payload.errors || {})[0]?.[0];
throw new Error(firstError || payload.message || '公司/账户保存失败');
}
notify(payload.message || '公司/账户已新增');
closeAccountCompanyDialog();
const target = new URL(
accountCompanyDialog?.dataset.ledgerAccountsUrl || window.location.href,
window.location.origin,
);
target.searchParams.set('accounts_short', payload.account?.account_short || '');
window.location.assign(target.toString());
} catch (error) {
if (accountCompanyError) {
accountCompanyError.textContent = error.message || '公司/账户保存失败';
accountCompanyError.hidden = false;
}
notify(error.message || '公司/账户保存失败', true);
} finally {
if (accountCompanySave) accountCompanySave.disabled = false;
accountCompanyForm.removeAttribute('aria-busy');
}
});
const accountColumnPicker = page.querySelector('[data-ledger-column-picker]');
const accountColumnPanel = accountColumnPicker?.querySelector('[data-ledger-column-panel]');

View File

@ -1454,7 +1454,6 @@
@php
$receiptMetricCards = [
['metric' => 'income_amount', 'label' => '账单收入', 'value' => $receiptStats['income_total']],
['metric' => 'installation_fee_amount', 'label' => '安装费金额', 'value' => $receiptStats['installation_fee_total'] ?? 0, 'installation_fee' => true],
['metric' => 'discount_amount', 'label' => '优惠收入', 'value' => $receiptStats['discount_total'] ?? 0],
['metric' => 'offset_amount', 'label' => '冲减收入', 'value' => $receiptStats['offset_total'] ?? 0],
['metric' => 'default_compensation_amount', 'label' => '违约赔付', 'value' => $receiptStats['default_compensation_total'] ?? 0],
@ -1468,39 +1467,23 @@
<div class="receipt-metric-board__head">
<div>
<strong>金额指标</strong>
<span>点击任一指标查看明细汇总;安装费金额按所选月份的发货时间归集。</span>
<span>点击任一指标,查看平台、店铺与账单项目明细;明细合计会与指标总额自动核对。</span>
</div>
<button type="button" class="receipt-metric-rules" data-receipt-rules-open>口径说明</button>
</div>
<div class="receipt-metric-grid">
@foreach ($receiptMetricCards as $metricCard)
@php
$isInstallationFee = !empty($metricCard['installation_fee']);
@endphp
@if ($isInstallationFee)
<button
type="button"
class="receipt-metric-card"
data-installation-fee-open
aria-label="查看{{ $metricCard['label'] }}明细"
>
<span class="receipt-metric-card__label">{{ $metricCard['label'] }}</span>
<strong class="receipt-metric-card__value">{{ number_format((float) $metricCard['value'] / 10000, 2) }}<small>万</small></strong>
<span class="receipt-metric-card__action">查看明细 →</span>
</button>
@else
<button
type="button"
class="receipt-metric-card"
data-receipt-metric="{{ $metricCard['metric'] }}"
data-receipt-metric-label="{{ $metricCard['label'] }}"
aria-label="查看{{ $metricCard['label'] }}明细"
>
<span class="receipt-metric-card__label">{{ $metricCard['label'] }}</span>
<strong class="receipt-metric-card__value">{{ number_format((float) $metricCard['value'] / 10000, 2) }}<small>万</small></strong>
<span class="receipt-metric-card__action">查看明细 →</span>
</button>
@endif
<button
type="button"
class="receipt-metric-card"
data-receipt-metric="{{ $metricCard['metric'] }}"
data-receipt-metric-label="{{ $metricCard['label'] }}"
aria-label="查看{{ $metricCard['label'] }}明细"
>
<span class="receipt-metric-card__label">{{ $metricCard['label'] }}</span>
<strong class="receipt-metric-card__value">{{ number_format((float) $metricCard['value'] / 10000, 2) }}<small>万</small></strong>
<span class="receipt-metric-card__action">查看明细 →</span>
</button>
@endforeach
</div>
</section>
@ -1634,61 +1617,6 @@
</div>
</dialog>
<dialog class="receipt-metric-dialog" id="installation-fee-dialog" aria-labelledby="installation-fee-dialog-title">
<div class="receipt-metric-dialog__head">
<div>
<h2 id="installation-fee-dialog-title">安装费金额 · 明细汇总</h2>
<p data-installation-fee-subtitle>按发货时间汇总店铺、安装公司和包安装分类</p>
</div>
<div class="receipt-metric-dialog__head-side">
<div class="receipt-metric-dialog__total">
<span>当前筛选合计(元)</span>
<strong data-installation-fee-total>¥0.00</strong>
</div>
<button type="button" class="receipt-dialog-close" data-installation-fee-close aria-label="关闭安装费明细">×</button>
</div>
</div>
<div class="receipt-metric-filters" aria-label="安装费明细筛选">
<label class="receipt-metric-filter">
<span>店铺名称</span>
<select data-installation-fee-store aria-label="筛选店铺名称">
<option value="">全部店铺</option>
</select>
</label>
<label class="receipt-metric-filter">
<span>安装公司</span>
<select data-installation-fee-company aria-label="筛选安装公司">
<option value="">全部安装公司</option>
</select>
</label>
<label class="receipt-metric-filter">
<span>收款渠道</span>
<select data-installation-fee-channel aria-label="筛选收款渠道">
<option value="">全部渠道</option>
</select>
</label>
</div>
<div class="receipt-metric-dialog__scroll">
<section class="receipt-metric-panel">
<table class="receipt-metric-table">
<thead>
<tr>
<th scope="col">店铺名称</th>
<th scope="col">安装公司</th>
<th scope="col">收款渠道</th>
<th scope="col" class="is-number">结算明细金额</th>
</tr>
</thead>
<tbody data-installation-fee-rows></tbody>
</table>
<div class="receipt-metric-dialog__empty" data-installation-fee-empty>正在读取安装费明细…</div>
</section>
</div>
<div class="receipt-metric-dialog__foot">
<span data-installation-fee-count>共 0 条明细汇总</span>
</div>
</dialog>
<dialog class="receipt-rules-dialog" id="receipt-rules-dialog" aria-labelledby="receipt-rules-dialog-title">
<div class="receipt-rules-dialog__head">
<div>
@ -2732,161 +2660,6 @@
}
})();
</script>
<script>
(function () {
var card = document.querySelector('[data-installation-fee-open]');
var dialog = document.getElementById('installation-fee-dialog');
if (!card || !dialog) { return; }
var endpoint = @json(route('business.financial-operations.performance-details.installation-fee-details'));
var subtitle = dialog.querySelector('[data-installation-fee-subtitle]');
var totalEl = dialog.querySelector('[data-installation-fee-total]');
var storeFilter = dialog.querySelector('[data-installation-fee-store]');
var companyFilter = dialog.querySelector('[data-installation-fee-company]');
var channelFilter = dialog.querySelector('[data-installation-fee-channel]');
var rowsEl = dialog.querySelector('[data-installation-fee-rows]');
var emptyEl = dialog.querySelector('[data-installation-fee-empty]');
var countEl = dialog.querySelector('[data-installation-fee-count]');
var allRows = [];
function money(value) {
return '¥' + Number(value || 0).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
}
function openDialog() {
if (typeof dialog.showModal === 'function') {
dialog.showModal();
} else {
dialog.setAttribute('open', '');
}
}
function closeDialog() {
if (typeof dialog.close === 'function') {
dialog.close();
} else {
dialog.removeAttribute('open');
}
}
function fillFilter(select, placeholder, values) {
select.replaceChildren();
var all = document.createElement('option');
all.value = '';
all.textContent = placeholder;
select.appendChild(all);
values.forEach(function (value) {
var option = document.createElement('option');
option.value = value;
option.textContent = value;
select.appendChild(option);
});
select.value = '';
}
function distinct(key) {
return Array.from(new Set(allRows.map(function (row) {
return String(row[key] || '').trim();
}).filter(Boolean))).sort(function (left, right) {
return left.localeCompare(right, 'zh-CN');
});
}
function render() {
var store = storeFilter.value;
var company = companyFilter.value;
var channel = channelFilter.value;
var rows = allRows.filter(function (row) {
return (!store || row.store_name === store)
&& (!company || row.installation_company === company)
&& (!channel || row.payment_channel === channel);
});
var total = rows.reduce(function (sum, row) {
return sum + Number(row.amount || 0);
}, 0);
rowsEl.replaceChildren();
rows.forEach(function (row) {
var tr = document.createElement('tr');
[row.store_name || '—', row.installation_company || '—', row.payment_channel || '—'].forEach(function (value) {
var td = document.createElement('td');
td.textContent = value;
tr.appendChild(td);
});
var amount = document.createElement('td');
amount.className = 'is-number';
amount.textContent = money(row.amount);
tr.appendChild(amount);
rowsEl.appendChild(tr);
});
totalEl.textContent = money(total);
countEl.textContent = '共 ' + rows.length.toLocaleString('zh-CN') + ' 条明细汇总';
emptyEl.hidden = rows.length > 0;
emptyEl.textContent = allRows.length
? '当前筛选条件下没有安装费明细。'
: '当前月份按发货时间没有安装费明细。';
}
function load() {
openDialog();
dialog.setAttribute('aria-busy', 'true');
allRows = [];
rowsEl.replaceChildren();
emptyEl.hidden = false;
emptyEl.textContent = '正在读取安装费明细…';
totalEl.textContent = '…';
countEl.textContent = '正在读取安装费明细';
var url = new URL(endpoint, window.location.origin);
new URLSearchParams(window.location.search).forEach(function (value, key) {
if (key === 'month') { url.searchParams.set(key, value); }
});
window.fetch(url.toString(), {
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' },
credentials: 'same-origin'
}).then(function (response) {
if (!response.ok) {
return response.json().catch(function () { return {}; }).then(function (data) {
throw new Error(data.message || '安装费明细读取失败。');
});
}
return response.json();
}).then(function (data) {
allRows = Array.isArray(data.rows) ? data.rows : [];
subtitle.textContent = data.month
? data.month + ' · 按发货时间汇总店铺、安装公司和包安装分类'
: '按发货时间汇总店铺、安装公司和包安装分类';
fillFilter(storeFilter, '全部店铺', distinct('store_name'));
fillFilter(companyFilter, '全部安装公司', distinct('installation_company'));
fillFilter(channelFilter, '全部渠道', distinct('payment_channel'));
render();
dialog.removeAttribute('aria-busy');
}).catch(function (error) {
allRows = [];
rowsEl.replaceChildren();
emptyEl.hidden = false;
emptyEl.textContent = error.message || '安装费明细读取失败,请稍后重试。';
totalEl.textContent = '—';
countEl.textContent = '未能读取安装费明细';
dialog.removeAttribute('aria-busy');
});
}
card.addEventListener('click', load);
[storeFilter, companyFilter, channelFilter].forEach(function (select) {
select.addEventListener('change', render);
});
dialog.querySelector('[data-installation-fee-close]').addEventListener('click', closeDialog);
dialog.addEventListener('click', function (event) {
if (event.target === dialog) { closeDialog(); }
});
})();
</script>
@elseif (in_array(($page['slug'] ?? ''), ['inventory-report', 'monthly-weighted-average-price'], true))
<form method="GET" action="{{ request()->url() }}" class="installation-filter-bar" aria-label="进销存报表筛选查询栏">
<label class="installation-filter-field">

View File

@ -0,0 +1,626 @@
<style>
.installation-fee-board {
margin: 1rem 1.5rem;
padding: 1rem;
border: 1px solid #d8d2c7;
border-radius: 0.75rem;
background: #fcfbf8;
}
.installation-fee-board__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.75rem;
}
.installation-fee-board__head strong {
display: block;
color: #171717;
font-size: 0.8125rem;
}
.installation-fee-board__head span {
display: block;
margin-top: 0.2rem;
color: #595959;
font-size: 0.6875rem;
line-height: 1.45;
}
.installation-fee-card {
display: flex;
width: min(100%, 15rem);
min-height: 6.5rem;
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
gap: 0.45rem;
padding: 0.8rem 0.9rem;
border: 1px solid #d8d2c7;
border-radius: 0.5rem;
background: #ffffff;
color: #171717;
text-align: left;
cursor: pointer;
}
.installation-fee-card:hover,
.installation-fee-card:focus-visible {
border-color: #b9954f;
background: #fffdf8;
outline: none;
}
.installation-fee-card:focus-visible {
box-shadow: 0 0 0 3px rgba(133, 102, 50, 0.2);
}
.installation-fee-card__label {
color: #595959;
font-size: 0.75rem;
}
.installation-fee-card__value {
color: #171717;
font-size: 1.25rem;
font-variant-numeric: tabular-nums;
}
.installation-fee-card__value small {
margin-left: 0.15rem;
color: #595959;
font-size: 0.75rem;
font-weight: 500;
}
.installation-fee-card__action {
color: #856632;
font-size: 0.6875rem;
}
.installation-fee-status {
color: #595959;
font-size: 0.6875rem;
}
.installation-fee-dialog {
width: min(68rem, calc(100vw - 2rem));
max-width: none;
max-height: calc(100vh - 2rem);
padding: 0;
border: 1px solid #d8d2c7;
border-radius: 0.75rem;
background: #ffffff;
color: #171717;
box-shadow: 0 10px 30px rgba(23, 23, 23, 0.18);
}
.installation-fee-dialog::backdrop {
background: rgba(23, 23, 23, 0.38);
}
.installation-fee-dialog[open] {
display: flex;
flex-direction: column;
}
.installation-fee-dialog__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.1rem 0.8rem;
border-bottom: 1px solid #eee8dd;
}
.installation-fee-dialog__head h2 {
margin: 0;
font-size: 0.9375rem;
}
.installation-fee-dialog__head p {
margin: 0.25rem 0 0;
color: #595959;
font-size: 0.6875rem;
}
.installation-fee-dialog__side {
display: flex;
align-items: flex-start;
gap: 1rem;
}
.installation-fee-dialog__total {
text-align: right;
}
.installation-fee-dialog__total span {
display: block;
color: #595959;
font-size: 0.6875rem;
}
.installation-fee-dialog__total strong {
display: block;
margin-top: 0.2rem;
font-size: 1rem;
font-variant-numeric: tabular-nums;
}
.installation-fee-dialog__close {
display: inline-flex;
width: 1.8rem;
height: 1.8rem;
align-items: center;
justify-content: center;
border: 1px solid #d8d2c7;
border-radius: 50%;
background: #ffffff;
color: #595959;
cursor: pointer;
}
.installation-fee-filters {
display: grid;
grid-template-columns: repeat(4, minmax(8.5rem, 1fr));
gap: 0.75rem;
padding: 0.8rem 1.1rem;
border-bottom: 1px solid #eee8dd;
}
.installation-fee-filter span {
display: block;
margin-bottom: 0.25rem;
color: #595959;
font-size: 0.6875rem;
}
.installation-fee-filter select {
width: 100%;
height: 2.1rem;
padding: 0 0.5rem;
border: 1px solid #d8d2c7;
border-radius: 0.375rem;
background: #ffffff;
color: #171717;
font-size: 0.75rem;
}
.installation-fee-dialog__scroll {
min-height: 12rem;
overflow: auto;
padding: 0 1.1rem;
}
.installation-fee-table {
width: 100%;
border-collapse: collapse;
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
}
.installation-fee-table th,
.installation-fee-table td {
padding: 0.6rem 0.5rem;
border-bottom: 1px solid #eee8dd;
text-align: left;
}
.installation-fee-table thead th {
position: sticky;
top: 0;
z-index: 1;
background: #f7f0de;
color: #595959;
font-weight: 600;
}
.installation-fee-table .is-number {
text-align: right;
}
.installation-fee-empty {
padding: 2rem 0.5rem;
color: #595959;
font-size: 0.75rem;
text-align: center;
}
.installation-fee-platform-summary {
margin: 0.75rem 0 1rem;
padding-top: 0.75rem;
border-top: 1px solid #d8d2c7;
}
.installation-fee-platform-summary h3 {
margin: 0 0 0.5rem;
color: #171717;
font-size: 0.75rem;
}
.installation-fee-platform-summary table {
width: 100%;
border-collapse: collapse;
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
}
.installation-fee-platform-summary th,
.installation-fee-platform-summary td {
padding: 0.5rem;
border-bottom: 1px solid #eee8dd;
text-align: left;
}
.installation-fee-platform-summary th {
background: #f7f0de;
color: #595959;
font-weight: 600;
}
.installation-fee-platform-summary .is-number {
text-align: right;
}
.installation-fee-dialog__foot {
padding: 0.65rem 1.1rem;
border-top: 1px solid #eee8dd;
color: #595959;
font-size: 0.6875rem;
}
@media (max-width: 900px) {
.installation-fee-filters { grid-template-columns: repeat(2, minmax(10rem, 1fr)); }
}
@media (max-width: 720px) {
.installation-fee-filters { grid-template-columns: 1fr; }
.installation-fee-dialog__head { flex-direction: column; }
.installation-fee-dialog__side { width: 100%; justify-content: space-between; }
}
</style>
<section class="installation-fee-board" aria-label="安装费金额汇总">
<div class="installation-fee-board__head">
<div>
<strong>安装费金额</strong>
<span>按客服审核时间汇总安装明细的结算明细金额;收款渠道为空归为包安装,有值归为不包安装。</span>
</div>
<span class="installation-fee-status" data-installation-fee-status>请选择完整的客服审核起止日期</span>
</div>
<button
type="button"
class="installation-fee-card"
data-installation-fee-open
data-url="{{ route('business.installation-details.summary') }}"
aria-label="查看安装费金额明细"
>
<span class="installation-fee-card__label">安装费金额</span>
<strong class="installation-fee-card__value"><span data-installation-fee-total>0.00</span><small>万</small></strong>
<span class="installation-fee-card__action">查看明细 →</span>
</button>
</section>
<dialog class="installation-fee-dialog" id="installation-fee-dialog" aria-labelledby="installation-fee-dialog-title">
<div class="installation-fee-dialog__head">
<div>
<h2 id="installation-fee-dialog-title">安装费金额 · 明细汇总</h2>
<p data-installation-fee-subtitle>按客服审核时间汇总店铺、安装公司和包安装分类</p>
</div>
<div class="installation-fee-dialog__side">
<div class="installation-fee-dialog__total">
<span>当前筛选合计(元)</span>
<strong data-installation-fee-current-total>¥0.00</strong>
</div>
<button type="button" class="installation-fee-dialog__close" data-installation-fee-close aria-label="关闭安装费明细">×</button>
</div>
</div>
<div class="installation-fee-filters" aria-label="安装费明细筛选">
<label class="installation-fee-filter">
<span>平台</span>
<select data-installation-fee-platform aria-label="筛选平台">
<option value="">全部平台</option>
</select>
</label>
<label class="installation-fee-filter">
<span>店铺名称</span>
<select data-installation-fee-store aria-label="筛选店铺名称">
<option value="">全部店铺</option>
</select>
</label>
<label class="installation-fee-filter">
<span>安装公司</span>
<select data-installation-fee-company aria-label="筛选安装公司">
<option value="">全部安装公司</option>
</select>
</label>
<label class="installation-fee-filter">
<span>收款渠道</span>
<select data-installation-fee-channel aria-label="筛选收款渠道">
<option value="">全部渠道</option>
</select>
</label>
</div>
<div class="installation-fee-dialog__scroll">
<table class="installation-fee-table">
<thead>
<tr>
<th scope="col">店铺名称</th>
<th scope="col">安装公司</th>
<th scope="col">收款渠道</th>
<th scope="col" class="is-number">结算明细金额</th>
</tr>
</thead>
<tbody data-installation-fee-rows></tbody>
</table>
<div class="installation-fee-empty" data-installation-fee-empty>请选择客服审核日期后查看明细</div>
<section class="installation-fee-platform-summary" aria-label="安装费平台汇总">
<h3>平台汇总</h3>
<table>
<thead>
<tr>
<th scope="col">平台</th>
<th scope="col" class="is-number">结算明细金额</th>
</tr>
</thead>
<tbody data-installation-fee-platform-rows></tbody>
</table>
</section>
</div>
<div class="installation-fee-dialog__foot">
<span data-installation-fee-count>共 0 条明细汇总</span>
</div>
</dialog>
<script>
(function () {
var form = document.getElementById('installationFilterForm');
var card = document.querySelector('[data-installation-fee-open]');
var dialog = document.getElementById('installation-fee-dialog');
if (!form || !card || !dialog) { return; }
var fromInput = form.elements.namedItem('customer_review_from');
var toInput = form.elements.namedItem('customer_review_to');
var cardTotal = card.querySelector('[data-installation-fee-total]');
var status = document.querySelector('[data-installation-fee-status]');
var subtitle = dialog.querySelector('[data-installation-fee-subtitle]');
var currentTotal = dialog.querySelector('[data-installation-fee-current-total]');
var platformFilter = dialog.querySelector('[data-installation-fee-platform]');
var storeFilter = dialog.querySelector('[data-installation-fee-store]');
var companyFilter = dialog.querySelector('[data-installation-fee-company]');
var channelFilter = dialog.querySelector('[data-installation-fee-channel]');
var rowsEl = dialog.querySelector('[data-installation-fee-rows]');
var platformRowsEl = dialog.querySelector('[data-installation-fee-platform-rows]');
var emptyEl = dialog.querySelector('[data-installation-fee-empty]');
var countEl = dialog.querySelector('[data-installation-fee-count]');
var allRows = [];
var loadedSignature = '';
var timer = null;
var controller = null;
function yuan(value) {
return '¥' + Number(value || 0).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
}
function wan(value) {
return (Number(value || 0) / 10000).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
}
function range() {
return {
from: String(fromInput?.value || ''),
to: String(toInput?.value || '')
};
}
function signature(value) {
return value.from + '|' + value.to;
}
function fillFilter(select, placeholder, values) {
select.replaceChildren();
var all = document.createElement('option');
all.value = '';
all.textContent = placeholder;
select.appendChild(all);
values.forEach(function (value) {
var option = document.createElement('option');
option.value = value;
option.textContent = value;
select.appendChild(option);
});
select.value = '';
}
function distinct(key) {
return Array.from(new Set(allRows.map(function (row) {
return String(row[key] || '').trim();
}).filter(Boolean))).sort(function (left, right) {
return left.localeCompare(right, 'zh-CN');
});
}
function renderRows() {
var platform = platformFilter.value;
var store = storeFilter.value;
var company = companyFilter.value;
var channel = channelFilter.value;
var rows = allRows.filter(function (row) {
return (!platform || row.platform === platform)
&& (!store || row.store_name === store)
&& (!company || row.installation_company === company)
&& (!channel || row.payment_channel === channel);
});
var total = rows.reduce(function (sum, row) {
return sum + Number(row.amount || 0);
}, 0);
rowsEl.replaceChildren();
rows.forEach(function (row) {
var tr = document.createElement('tr');
[row.store_name || '—', row.installation_company || '—', row.payment_channel || '—'].forEach(function (value) {
var td = document.createElement('td');
td.textContent = value;
tr.appendChild(td);
});
var amount = document.createElement('td');
amount.className = 'is-number';
amount.textContent = yuan(row.amount);
tr.appendChild(amount);
rowsEl.appendChild(tr);
});
var byPlatform = {};
rows.forEach(function (row) {
var key = row.platform || '其他';
byPlatform[key] ??= 0;
byPlatform[key] += Number(row.amount || 0);
});
platformRowsEl.replaceChildren();
Object.keys(byPlatform).sort(function (left, right) {
return Math.abs(byPlatform[right]) - Math.abs(byPlatform[left])
|| left.localeCompare(right, 'zh-CN');
}).forEach(function (name) {
var tr = document.createElement('tr');
var platformCell = document.createElement('td');
platformCell.textContent = name;
tr.appendChild(platformCell);
var amount = document.createElement('td');
amount.className = 'is-number';
amount.textContent = yuan(byPlatform[name]);
tr.appendChild(amount);
platformRowsEl.appendChild(tr);
});
currentTotal.textContent = yuan(total);
countEl.textContent = '共 ' + rows.length.toLocaleString('zh-CN') + ' 条明细汇总';
emptyEl.hidden = rows.length > 0;
emptyEl.textContent = allRows.length
? '当前筛选条件下没有安装费明细。'
: '当前客服审核时间内没有安装费明细。';
}
function resetDialog(message) {
allRows = [];
loadedSignature = '';
rowsEl.replaceChildren();
platformRowsEl.replaceChildren();
currentTotal.textContent = '¥0.00';
countEl.textContent = '共 0 条明细汇总';
emptyEl.hidden = false;
emptyEl.textContent = message;
fillFilter(storeFilter, '全部店铺', []);
fillFilter(platformFilter, '全部平台', []);
fillFilter(companyFilter, '全部安装公司', []);
fillFilter(channelFilter, '全部渠道', []);
}
function openDialog() {
if (typeof dialog.showModal === 'function') {
dialog.showModal();
} else {
dialog.setAttribute('open', '');
}
}
function closeDialog() {
if (typeof dialog.close === 'function') {
dialog.close();
} else {
dialog.removeAttribute('open');
}
}
function refresh(force) {
var value = range();
if (!value.from || !value.to) {
cardTotal.textContent = '0.00';
status.textContent = '请选择完整的客服审核起止日期';
resetDialog('请选择完整的客服审核起止日期');
return Promise.resolve(false);
}
if (value.from > value.to) {
cardTotal.textContent = '0.00';
status.textContent = '客服审核结束日期不能早于开始日期';
resetDialog('客服审核结束日期不能早于开始日期');
return Promise.resolve(false);
}
var currentSignature = signature(value);
if (!force && loadedSignature === currentSignature) {
return Promise.resolve(true);
}
if (controller) { controller.abort(); }
controller = new AbortController();
dialog.setAttribute('aria-busy', 'true');
status.textContent = '正在汇总安装费…';
cardTotal.textContent = '…';
var url = new URL(card.dataset.url, window.location.origin);
url.searchParams.set('customer_review_from', value.from);
url.searchParams.set('customer_review_to', value.to);
return window.fetch(url.toString(), {
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' },
credentials: 'same-origin',
signal: controller.signal
}).then(function (response) {
return response.json().catch(function () { return {}; }).then(function (data) {
if (!response.ok) {
throw new Error(data.message || '安装费汇总读取失败。');
}
return data;
});
}).then(function (data) {
allRows = Array.isArray(data.rows) ? data.rows : [];
loadedSignature = currentSignature;
cardTotal.textContent = wan(data.total);
status.textContent = value.from + ' 至 ' + value.to + ' · ' + allRows.length + ' 条汇总';
subtitle.textContent = value.from + ' 至 ' + value.to + ' · 按客服审核时间汇总';
fillFilter(platformFilter, '全部平台', distinct('platform'));
fillFilter(storeFilter, '全部店铺', distinct('store_name'));
fillFilter(companyFilter, '全部安装公司', distinct('installation_company'));
fillFilter(channelFilter, '全部渠道', distinct('payment_channel'));
renderRows();
dialog.removeAttribute('aria-busy');
return true;
}).catch(function (error) {
if (error && error.name === 'AbortError') { return false; }
cardTotal.textContent = '0.00';
status.textContent = error.message || '安装费汇总读取失败';
resetDialog(error.message || '安装费汇总读取失败,请稍后重试。');
dialog.removeAttribute('aria-busy');
return false;
});
}
function scheduleRefresh() {
window.clearTimeout(timer);
timer = window.setTimeout(function () { refresh(true); }, 250);
}
card.addEventListener('click', function () {
openDialog();
refresh(false);
});
[fromInput, toInput].forEach(function (input) {
if (input) { input.addEventListener('change', scheduleRefresh); }
});
[platformFilter, storeFilter, companyFilter, channelFilter].forEach(function (select) {
select.addEventListener('change', renderRows);
});
dialog.querySelector('[data-installation-fee-close]').addEventListener('click', closeDialog);
dialog.addEventListener('click', function (event) {
if (event.target === dialog) { closeDialog(); }
});
refresh(true);
})();
</script>

View File

@ -238,11 +238,59 @@
font-size: 0.75rem;
}
.installation-date-filter {
display: flex;
grid-column: span 2;
align-items: center;
gap: 0.4rem;
min-width: 0;
color: #595959;
font-size: 0.75rem;
white-space: nowrap;
}
.installation-date-filter__label {
flex: 0 0 auto;
}
.installation-date-filter__range {
flex: 1 1 auto;
min-width: 0;
}
.installation-date-filter .upstream-data-date input {
height: 2.1rem;
font-size: 0.75rem;
}
.installation-date-range-error {
grid-column: 1 / -1;
margin: 0;
color: #b80000;
font-size: 0.75rem;
}
.installation-date-range-error[hidden] {
display: none;
}
.installation-filter-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
@media (max-width: 1100px) {
.installation-filter-bar { grid-template-columns: repeat(2, minmax(14rem, 1fr)); }
.installation-date-filter { grid-column: 1 / -1; }
}
@media (max-width: 650px) {
.installation-filter-bar { grid-template-columns: minmax(0, 1fr); padding: 1rem; }
.installation-date-filter {
flex-direction: column;
align-items: stretch;
gap: 0.25rem;
}
}
.biz-card-head {
@ -302,6 +350,113 @@
background: #f9fafb;
}
.installation-main-row {
cursor: pointer;
}
.installation-details-table-wrap {
overflow-x: auto;
}
.installation-details-table {
width: max-content;
min-width: 100%;
table-layout: auto;
font-size: 0.625rem;
}
.installation-details-table thead th {
height: auto;
padding: 0.55rem 0.4rem;
writing-mode: horizontal-tb;
vertical-align: middle;
line-height: 1.25;
letter-spacing: 0;
white-space: nowrap;
}
.installation-details-table tbody td {
max-width: 8rem;
padding: 0.35rem 0.4rem;
overflow: hidden;
vertical-align: middle;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.installation-details-table .biz-badge {
padding: 0.05rem 0.15rem;
border-radius: 0.2rem;
font-size: 0.5625rem;
line-height: 1.2;
white-space: normal;
}
.installation-details-table th:first-child,
.installation-details-table td:first-child {
min-width: 7rem;
width: auto;
}
.installation-expand {
width: 2rem;
height: 2rem;
padding: 0;
border: 1px solid #d8d2c7;
border-radius: 0.375rem;
background: #ffffff;
color: #856632;
cursor: pointer;
}
.installation-detail-row td {
padding: 0.75rem 1rem 1rem 3rem;
background: #fbfaf7;
}
.installation-detail-table {
width: max-content;
min-width: 100%;
table-layout: auto;
border-collapse: collapse;
font-size: 0.625rem;
}
.installation-detail-table th,
.installation-detail-table td {
padding: 0.35rem 0.4rem;
border: 1px solid #e5e0d7;
white-space: nowrap;
vertical-align: middle;
text-align: left;
}
.installation-detail-table th {
background: #f3efe6;
color: #595959;
font-weight: 600;
height: auto;
writing-mode: horizontal-tb;
line-height: 1.25;
}
.installation-detail-table td {
max-width: 9rem;
overflow: hidden;
text-overflow: ellipsis;
}
.installation-detail-empty,
.installation-detail-error {
color: #595959;
padding: 0.5rem 0;
}
.installation-detail-error {
color: #b80000;
}
.biz-table .num {
text-align: right;
font-variant-numeric: tabular-nums;
@ -616,6 +771,106 @@
@include($pageView)
@elseif ($descriptor)
<div class="biz-card mt-6">
@if ($page['slug'] === 'installation-details')
@include('business.pages.partials.date-range-picker')
<form method="GET" id="installationFilterForm" action="{{ request()->url() }}" class="installation-filter-bar" aria-label="安装明细筛选查询栏" autocomplete="off">
@php
$dateRangeFilters = [
['from' => 'created_from', 'to' => 'created_to', 'label' => '创建日期:', 'aria' => '创建日期'],
['from' => 'customer_review_from', 'to' => 'customer_review_to', 'label' => '客服审核时间:', 'aria' => '客服审核时间'],
['from' => 'finance_review_from', 'to' => 'finance_review_to', 'label' => '财务审核时间:', 'aria' => '财务审核时间'],
];
$filterFields = [
['name' => 'push_status', 'label' => '推送状态:'], ['name' => 'order_no', 'label' => '订单编号:'],
['name' => 'customer_review', 'label' => '客服审核:', 'options' => ['0'=>'未审核','1'=>'已审核']], ['name' => 'finance_review', 'label' => '财务审核:', 'options' => ['0'=>'未审核','1'=>'已审核']],
['name' => 'quote_status', 'label' => '报价状态:'], ['name' => 'arranged_status', 'label' => '是否安排:', 'options' => ['0'=>'未安排','1'=>'已安排']],
['name' => 'fee_payer', 'label' => '安装费承担方:'], ['name' => 'completed_status', 'label' => '是否完工:', 'options' => ['0'=>'未完工','1'=>'已完工']],
['name' => 'shop_name', 'label' => '选择店铺:'], ['name' => 'delete_status', 'label' => '选择删除:', 'options' => ['0'=>'未删除','1'=>'已删除']],
['name' => 'installation_company', 'label' => '安装公司:'], ['name' => 'installation_no', 'label' => '安装单号:'],
['name' => 'order_type', 'label' => '订单类型:'], ['name' => 'product_id', 'label' => '商品ID:'],
];
@endphp
@foreach ($dateRangeFilters as $dateRange)
<div class="installation-date-filter">
<span class="installation-date-filter__label">{{ $dateRange['label'] }}</span>
<div class="upstream-data-date-range installation-date-filter__range">
<span class="upstream-data-date" data-date-picker>
<input id="{{ $dateRange['from'] }}" type="text" name="{{ $dateRange['from'] }}" value="{{ request($dateRange['from']) }}" readonly autocomplete="off" placeholder="开始日期" aria-label="{{ $dateRange['aria'] }}开始日期" aria-describedby="installationDateRangeError" data-date-input>
<button type="button" class="upstream-data-date__clear" data-date-clear hidden aria-label="清除{{ $dateRange['aria'] }}开始日期">
<span class="material-icons-outlined" aria-hidden="true">close</span>
</button>
<button type="button" class="upstream-data-date__pick" data-date-toggle aria-label="选择{{ $dateRange['aria'] }}开始日期">
<span class="material-icons-outlined" aria-hidden="true">calendar_today</span>
</button>
<div class="upstream-data-calendar" role="dialog" aria-label="{{ $dateRange['aria'] }}开始日期日历" data-date-calendar hidden></div>
</span>
<b class="upstream-data-date-range__sep">至</b>
<span class="upstream-data-date" data-date-picker>
<input id="{{ $dateRange['to'] }}" type="text" name="{{ $dateRange['to'] }}" value="{{ request($dateRange['to']) }}" readonly autocomplete="off" placeholder="结束日期" aria-label="{{ $dateRange['aria'] }}结束日期" aria-describedby="installationDateRangeError" data-date-input>
<button type="button" class="upstream-data-date__clear" data-date-clear hidden aria-label="清除{{ $dateRange['aria'] }}结束日期">
<span class="material-icons-outlined" aria-hidden="true">close</span>
</button>
<button type="button" class="upstream-data-date__pick" data-date-toggle aria-label="选择{{ $dateRange['aria'] }}结束日期">
<span class="material-icons-outlined" aria-hidden="true">calendar_today</span>
</button>
<div class="upstream-data-calendar" role="dialog" aria-label="{{ $dateRange['aria'] }}结束日期日历" data-date-calendar hidden></div>
</span>
</div>
</div>
@endforeach
@foreach ($filterFields as $filter)
<label class="installation-filter-field">
<span>{{ $filter['label'] }}</span>
@if (isset($filter['options']))
<select name="{{ $filter['name'] }}"><option value="">全部</option>@foreach ($filter['options'] as $value => $label)<option value="{{ $value }}" @selected((string) request($filter['name']) === (string) $value)>{{ $label }}</option>@endforeach</select>
@else
<input type="{{ $filter['type'] ?? 'text' }}" name="{{ $filter['name'] }}" value="{{ request($filter['name']) }}" @if (($filter['type'] ?? '') !== 'date') placeholder="按原值精确查询" @endif>
@endif
</label>
@endforeach
<p id="installationDateRangeError" class="installation-date-range-error" role="alert" hidden>结束日期不能早于开始日期。</p>
<div class="installation-filter-actions">
<button type="submit" class="biz-btn biz-btn-primary">查询</button>
<a href="{{ request()->url() }}" class="biz-btn biz-btn-ghost">清除</a>
<a href="{{ route('business.installation-details.export', request()->query()) }}" class="biz-btn biz-btn-ghost">导出</a>
</div>
</form>
<script>
(function () {
var form = document.getElementById('installationFilterForm');
if (!form) { return; }
if (window.CaiWuDatePicker) {
window.CaiWuDatePicker.setup(form);
}
var error = document.getElementById('installationDateRangeError');
var ranges = [
['created_from', 'created_to'],
['customer_review_from', 'customer_review_to'],
['finance_review_from', 'finance_review_to']
];
form.addEventListener('submit', function (event) {
var invalidEnd = null;
ranges.forEach(function (names) {
var from = form.elements.namedItem(names[0]);
var to = form.elements.namedItem(names[1]);
var invalid = Boolean(from && to && from.value && to.value && from.value > to.value);
if (from) { from.setAttribute('aria-invalid', invalid ? 'true' : 'false'); }
if (to) { to.setAttribute('aria-invalid', invalid ? 'true' : 'false'); }
if (invalid && !invalidEnd) { invalidEnd = to; }
});
error.hidden = !invalidEnd;
if (invalidEnd) {
event.preventDefault();
invalidEnd.focus();
}
});
})();
</script>
@include('business.pages.partials.installation-fee-summary')
@endif
<div class="biz-card-head">
<div class="biz-card-title">
<span class="material-icons-outlined" aria-hidden="true">table_view</span>
@ -687,13 +942,13 @@
@endif
</form>
@endif
<div class="biz-table-wrap">
<table class="biz-table">
<div class="biz-table-wrap @if ($page['slug'] === 'installation-details') installation-details-table-wrap @endif">
<table class="biz-table @if ($page['slug'] === 'installation-details') installation-details-table @endif">
<thead>
<tr>
<th>序号</th>
@if ($page['slug'] !== 'installation-details')<th>序号</th>@endif
@foreach ($descriptor->fields as $field)
<th class="@if ($field['currency']) num @endif">{{ $field['label'] }}</th>
<th class="@if ($field['currency']) num @endif" @if ($page['slug'] === 'installation-details') title="{{ $field['label'] }}" @endif>{{ $field['label'] }}</th>
@endforeach
@unless ($descriptor->readOnly)
<th class="actions-cell" data-finance-fixed="end">操作</th>
@ -702,13 +957,13 @@
</thead>
<tbody>
@forelse ($rows as $row)
<tr>
<td>{{ $row->id }}</td>
<tr @if ($page['slug'] === 'installation-details') class="installation-main-row" data-installation-row data-guid="{{ $row->related_guid ?? '' }}" tabindex="0" aria-label="查看安装商品明细" @endif>
@if ($page['slug'] !== 'installation-details')<td>{{ $row->id }}</td>@endif
@foreach ($descriptor->fields as $field)
@php
$val = $row->{$field['name']};
@endphp
<td class="@if ($field['currency']) num @endif">
<td class="@if ($field['currency']) num @endif" @if ($page['slug'] === 'installation-details' && is_scalar($val)) title="{{ $val }}" @endif>
@if ($field['type'] === 'boolean')
@if ($val)
<span class="biz-badge biz-badge-yes">是</span>
@ -746,7 +1001,7 @@
</tr>
@empty
<tr>
<td colspan="{{ count($descriptor->fields) + 1 + ($descriptor->readOnly ? 0 : 1) }}">
<td colspan="{{ count($descriptor->fields) + ($page['slug'] === 'installation-details' ? 0 : 1) + ($descriptor->readOnly ? 0 : 1) }}">
<div class="biz-empty">
<span class="material-icons-outlined" aria-hidden="true">inbox</span>
<div>{{ $descriptor->readOnly ? '暂无工作平台数据' : '暂无数据,点击右上角"新增"开始录入' }}</div>
@ -774,6 +1029,65 @@
</span>
</div>
@if ($page['slug'] === 'installation-details')
<section class="installation-detail-panel" data-installation-detail-panel aria-label="明细子表">
<div class="biz-card-head"><div class="biz-card-title">明细子表</div></div>
<div class="biz-table-wrap installation-details-table-wrap" data-installation-detail-content>
<div class="installation-detail-empty">请点击上方主表记录查看商品明细</div>
</div>
</section>
@endif
@if ($page['slug'] === 'installation-details')
<script>
(() => {
const detailUrl = @json(route('business.installation-details.details', ['guid' => '__GUID__']));
const columns = [
['transaction_no', '交易单号'], ['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', '修改时间'],
];
const escapeHtml = (value) => String(value ?? '').replace(/[&<>"']/g, (char) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[char]));
const renderDetails = (items) => {
if (!items.length) return '<div class="installation-detail-empty">暂无商品明细</div>';
const head = columns.map(([, label]) => `<th>${escapeHtml(label)}</th>`).join('');
const body = items.map((item) => `<tr>${columns.map(([key]) => {
const value = escapeHtml(item[key]);
return `<td title="${value}">${value}</td>`;
}).join('')}</tr>`).join('');
return `<table class="installation-detail-table"><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
};
const detailPanel = document.querySelector('[data-installation-detail-panel]');
const detailContent = detailPanel?.querySelector('[data-installation-detail-content]');
let detailRequest = 0;
document.querySelectorAll('[data-installation-row]').forEach((row) => {
row.addEventListener('keydown', (event) => {
if (event.target === row && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); row.click(); }
});
row.addEventListener('click', async () => {
if (!detailContent) return;
const requestId = ++detailRequest;
detailContent.innerHTML = '<div>正在加载明细…</div>';
try {
const response = await fetch(detailUrl.replace('__GUID__', encodeURIComponent(row.dataset.guid)), {headers: {'Accept': 'application/json'}});
if (!response.ok) throw new Error('明细加载失败');
const payload = await response.json();
if (requestId !== detailRequest) return;
detailContent.innerHTML = renderDetails(payload.data || []);
if (payload.has_more) detailContent.insertAdjacentHTML('beforeend', '<div class="installation-detail-empty">当前仅展示前 100 条商品明细,不代表全部。</div>');
} catch (error) {
if (requestId !== detailRequest) return;
detailContent.innerHTML = '<div class="installation-detail-error">明细加载失败,请稍后重试</div>';
}
});
});
})();
</script>
@endif
</div>
@unless ($descriptor->readOnly)

View File

@ -49,7 +49,11 @@
.sys-todo-table__index-cell { width:3.6rem; min-width:3.6rem; text-align:center; }
.sys-todo-table__index { color:#6d5630; font-weight:600; font-variant-numeric:tabular-nums; white-space:nowrap; }
.sys-todo-table__group { padding-top:.9rem; padding-bottom:.9rem; background:#fcfaf5; font-weight:600; line-height:1.7; vertical-align:middle; }
.sys-todo-table__group-content { display:inline-flex; align-items:flex-start; gap:.38rem; min-width:0; }
.sys-todo-table__group-text { min-width:0; }
.sys-todo-table__level { display:inline-flex; flex:0 0 auto; align-items:center; justify-content:center; min-width:1.35rem; height:1.35rem; margin-top:.05rem; border:1px solid #d8c9aa; border-radius:999px; background:#fffaf0; color:var(--sys-gold); font-size:.68rem; font-weight:700; font-variant-numeric:tabular-nums; }
.sys-todo-table__type { position:sticky; left:0; z-index:3; width:5.4rem; min-width:5.4rem; text-align:center; white-space:nowrap; box-shadow:1px 0 0 #e5ddcf; }
.sys-todo-table__type .sys-todo-table__group-text { white-space:normal; }
.sys-todo-table__section { min-width:11rem; max-width:17rem; }
.sys-todo-table__title, .sys-todo-table__progress, .sys-todo-table__notes { line-height:1.45; white-space:pre-line; overflow-wrap:anywhere; }
.sys-todo-table__title { min-width:17rem; max-width:24rem; font-weight:600; }
@ -199,7 +203,9 @@
<option value="{{ $category }}" @selected($todoFilters['category'] === $category)>{{ $category }}</option>
@endforeach
</select></label>
<label>类型内序号<input type="number" name="sequence" min="1" step="1" value="{{ $todoFilters['sequence'] ?: '' }}" placeholder="如:5"></label>
<label>类型序号<input type="number" name="type_sequence" min="1" step="1" value="{{ $todoFilters['type_sequence'] ?: '' }}" placeholder="如:1"></label>
<label>内容序号<input type="number" name="content_sequence" min="1" step="1" value="{{ $todoFilters['content_sequence'] ?: '' }}" placeholder="如:2"></label>
<label>待办序号<input type="number" name="item_sequence" min="1" step="1" value="{{ $todoFilters['item_sequence'] ?: '' }}" placeholder="如:3"></label>
@php
$selectedOwners = $todoOwners->filter(
static fn (string $owner): bool => in_array($owner, $todoFilters['owners'], true)
@ -266,16 +272,16 @@
));
};
$sectionLabels = $items
->map(static fn ($groupItem): string => $cleanDisplayLabel($groupItem->section))
->map(static fn ($groupItem): string => trim((string) $groupItem->section_display))
->all();
$titleLabels = $items
->map(static fn ($groupItem): string => $cleanDisplayLabel($groupItem->title))
->all();
$groupValues = [
'category' => $items->map(static fn ($groupItem): string => trim((string) ($groupItem->category ?? '')))->all(),
'section' => $items->map(static fn ($groupItem, int $index): string => trim((string) ($groupItem->category ?? ''))
'category' => $items->map(static fn ($groupItem): string => (string) ((int) $groupItem->type_sequence))->all(),
'section' => $items->map(static fn ($groupItem): string => (int) $groupItem->type_sequence
. "\0"
. ($sectionLabels[$index] ?? ''))->all(),
. (int) $groupItem->content_sequence)->all(),
];
$groupRowspans = ['category' => [], 'section' => []];
foreach (['category', 'section'] as $groupField) {
@ -327,7 +333,7 @@
<table class="sys-todo-table" aria-label="财务系统化待办清单">
<thead>
<tr>
<th scope="col">类型</th><th scope="col">内容</th><th scope="col">序号</th><th scope="col">待办事项</th><th scope="col">总负责人</th><th scope="col">责任人</th>
<th scope="col">类型</th><th scope="col">内容</th><th scope="col">待办序号</th><th scope="col">待办事项</th><th scope="col">总负责人</th><th scope="col">责任人</th>
<th scope="col">开始时间</th><th scope="col">完成时间</th><th scope="col">状态</th><th scope="col">完成情况</th><th scope="col">完成截图</th><th scope="col">备注</th>
</tr>
</thead>
@ -371,14 +377,22 @@
@endphp
<tr id="sys-todo-row-{{ $item->id }}" class="{{ $highlightId === (int) $item->id ? 'is-highlighted' : '' }}" tabindex="0" data-sys-todo-row-edit="{{ $todoPayloadEncoded }}" title="点击整行直接编辑">
@if ($categorySpan !== 0)
<td class="sys-todo-table__group sys-todo-table__type" @if ($categorySpan > 1) rowspan="{{ $categorySpan }}" @endif>{{ $item->category ?: '—' }}</td>
<td class="sys-todo-table__group sys-todo-table__type" @if ($categorySpan > 1) rowspan="{{ $categorySpan }}" @endif>
<span class="sys-todo-table__group-content">
<span class="sys-todo-table__level" title="类型序号">{{ (int) $item->type_sequence }}</span>
<span class="sys-todo-table__group-text">{{ $item->category_display ?: '未分类' }}</span>
</span>
</td>
@endif
@if ($sectionSpan !== 0)
<td class="sys-todo-table__group sys-todo-table__section" @if ($sectionSpan > 1) rowspan="{{ $sectionSpan }}" @endif>
<div class="sys-todo-table__clamp" title="{{ $sectionLabels[$loop->index] ?: '—' }}">{{ $sectionLabels[$loop->index] ?: '—' }}</div>
<span class="sys-todo-table__group-content">
<span class="sys-todo-table__level" title="内容序号">{{ (int) $item->content_sequence }}</span>
<span class="sys-todo-table__group-text sys-todo-table__clamp" title="{{ $sectionLabels[$loop->index] ?: '未填写内容' }}">{{ $sectionLabels[$loop->index] ?: '未填写内容' }}</span>
</span>
</td>
@endif
<td class="sys-todo-table__index-cell"><span class="sys-todo-table__index" title="当前类型内序号">{{ (int) ($item->category_sequence ?? 0) }}</span></td>
<td class="sys-todo-table__index-cell"><span class="sys-todo-table__index" title="当前内容内待办序号">{{ (int) $item->item_sequence }}</span></td>
<td class="sys-todo-table__title"><div class="sys-todo-table__clamp" title="{{ $titleLabels[$loop->index] }}">{{ $titleLabels[$loop->index] }}</div></td>
<td>{{ $item->owner ?: '—' }}</td>
<td>{{ $item->assignee ?: '—' }}</td>

View File

@ -268,10 +268,6 @@ Route::get('workspace/product-inventory/monthly-weighted-average-price/export',
->middleware('business.page:financial-operations,performance-details')
->name('business.financial-operations.performance-details.metric-details');
Route::get('workspace/financial-operations/performance-details/installation-fee-details', 'Business\Pages@installationFeeMetricDetails')
->middleware('business.page:financial-operations,performance-details')
->name('business.financial-operations.performance-details.installation-fee-details');
Route::get('workspace/financial-operations/product-costs/export', 'Business\Pages@exportProductCosts')
->middleware('business.page:financial-operations,product-costs')
->name('business.financial-operations.product-costs.export');
@ -572,16 +568,28 @@ Route::get('workspace/courier-payables/third-party-warehouse-storage/settlement-
->middleware('business.page:courier-payables,third-party-warehouse-storage')
->name('business.courier-payables.third-party-warehouse-storage.settlement-export');
Route::get('workspace/{section}/{page}/{tab}/export', 'Business\Pages@export')
->name('business.pages.export');
Route::middleware('business.page.viewer')->group(function (): void {
Route::get('workspace/{section}/{page}/{tab}/export', 'Business\Pages@export')
->name('business.pages.export');
Route::get('workspace/{section}/{page}/{tab}/template', 'Business\Pages@storeReceivingAccountTemplate')
->name('business.pages.store-receiving-account-template');
Route::get('workspace/{section}/{page}/{tab}/template', 'Business\Pages@storeReceivingAccountTemplate')
->name('business.pages.store-receiving-account-template');
});
Route::get('workspace/fund-management/fund-forecast/template', 'Business\Pages@fundForecastTemplate')
->middleware('business.page:fund-management,fund-forecast')
->name('business.fund-forecast.template');
Route::get('workspace/financial-operations/installation-details/summary', 'Business\Pages@installationDetailsSummary')
->name('business.installation-details.summary');
Route::get('workspace/financial-operations/installation-details/details/{guid}', 'Business\Pages@installationDetails')
->where('guid', '[A-Za-z0-9-]+')
->name('business.installation-details.details');
Route::get('workspace/financial-operations/installation-details/export', 'Business\Pages@exportInstallationDetails')
->name('business.installation-details.export');
Route::get('workspace/trademark-management/store-report-data-logic/edit', 'Business\Pages@editStoreReportLogic')
->middleware('business.page:trademark-management,store-report-data-logic')
->name('business.store-report-logic.edit');
@ -717,6 +725,10 @@ Route::post('workspace/financial-ledger/accounts/status', [\App\Http\Controllers
->middleware('business.page:financial-operations,ledger')
->name('business.financial-ledger.accounts.status');
Route::post('workspace/financial-ledger/accounts', [\App\Http\Controllers\Business\FinanceLedger::class, 'storeAccount'])
->middleware('business.page:financial-operations,ledger')
->name('business.financial-ledger.accounts.store');
Route::post('workspace/financial-ledger/accounts/type', [\App\Http\Controllers\Business\FinanceLedger::class, 'updateAccountType'])
->middleware('business.page:financial-operations,ledger')
->name('business.financial-ledger.accounts.type');
@ -800,21 +812,23 @@ Route::get('workspace/financial-ledger/{page?}/{tab?}', function (Request $reque
return redirect()->to($query === [] ? $target : $target . '?' . http_build_query($query));
})->name('business.financial-ledger.legacy');
Route::get('workspace/{section}/{page?}/{tab?}', 'Business\Pages@show')
->name('business.pages.show');
Route::middleware('business.page.viewer')->group(function (): void {
Route::get('workspace/{section}/{page?}/{tab?}', 'Business\Pages@show')
->name('business.pages.show');
Route::post('workspace/{section}/{page}/import/{tab?}', 'Business\Pages@import')
->middleware('import')
->name('business.pages.import');
Route::post('workspace/{section}/{page}/import/{tab?}', 'Business\Pages@import')
->middleware('import')
->name('business.pages.import');
Route::post('workspace/{section}/{page}/{tab?}', 'Business\Pages@store')
->name('business.pages.store');
Route::post('workspace/{section}/{page}/{tab?}', 'Business\Pages@store')
->name('business.pages.store');
Route::patch('workspace/{section}/{page}/{id}/{tab?}', 'Business\Pages@update')
->name('business.pages.update');
Route::patch('workspace/{section}/{page}/{id}/{tab?}', 'Business\Pages@update')
->name('business.pages.update');
Route::delete('workspace/{section}/{page}/{id}/{tab?}', 'Business\Pages@destroy')
->name('business.pages.destroy');
Route::delete('workspace/{section}/{page}/{id}/{tab?}', 'Business\Pages@destroy')
->name('business.pages.destroy');
});
Route::group(['as' => 'system-settings.', 'prefix' => 'system-settings'], function () {
Route::resource('groups', 'SystemSettings\UserGroups')->except('show');

View File

@ -80,6 +80,13 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase
]);
}
private function createAccountUrl(): string
{
return route('business.financial-ledger.accounts.store', [
'company_id' => $this->company->id,
]);
}
public function test_account_table_renders_status_selector_with_four_options(): void
{
$this->loginAs()->get($this->pageUrl())
@ -130,6 +137,19 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase
->assertSee('data-ledger-column-picker', false)
->assertSee('data-ledger-column-panel', false)
->assertSee('data-ledger-edit-mode-toggle', false)
->assertSee('data-ledger-add-company', false)
->assertSee('data-ledger-company-dialog', false)
->assertSee('新增公司/账户')
->assertSee('name="company_name"', false)
->assertSee('name="company_register_place"', false)
->assertSee('name="company_business_type"', false)
->assertSee('name="company_tax_type"', false)
->assertSee('name="company_status"', false)
->assertSee('name="company_basic_bank"', false)
->assertSee('name="company_basic_account_last4"', false)
->assertSee('name="company_remark"', false)
->assertSee('name="opening_balance"', false)
->assertSee('id="ledger-company-options"', false)
->assertSee('aria-checked="false"', false)
->assertSee('ledger-account-editor', false)
->assertSee('data-account-edit-display', false)
@ -736,6 +756,137 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase
])->assertUnprocessable()->assertJsonValidationErrors('account_should_keep');
}
public function test_editor_can_create_new_company_and_account_with_opening_balance(): void
{
$this->loginAs()->postJson($this->createAccountUrl(), [
'company_name' => '新设财务公司',
'company_register_place' => '上海市浦东新区',
'company_business_type' => '有限责任公司',
'company_tax_type' => '一般纳税人',
'company_status' => '正常',
'company_basic_bank' => '中国农业银行上海浦东支行',
'company_basic_account_last4' => '1234',
'company_remark' => '新设公司财务备注',
'account_short' => '新设财务公司农行',
'account_type' => '公户',
'status' => 'limited',
'region' => '上海',
'account_no' => '6222000000001234',
'bank_name' => '中国农业银行',
'bank_city_branch' => '上海浦东支行',
'company_plan' => '待注销',
'account_should_keep' => '否',
'opening_date' => '2026-10-20',
'opening_balance' => '12345.67',
])
->assertCreated()
->assertJsonPath('account.company_name', '新设财务公司')
->assertJsonPath('account.account_short', '新设财务公司农行')
->assertJsonPath('account.company_created', true)
->assertJsonPath('account.status', 'limited')
->assertJsonPath('message', '已新增公司并建立首个账户。');
$this->assertDatabaseHas('fin_companies', [
'name' => '新设财务公司',
'register_place' => '上海市浦东新区',
'business_type' => '有限责任公司',
'tax_type' => '一般纳税人',
'status' => '正常',
'basic_bank' => '中国农业银行上海浦东支行',
'basic_account_last4' => '1234',
'remark' => '新设公司财务备注',
], 'business_mirror');
$this->assertDatabaseHas('fin_accounts', [
'company_name' => '新设财务公司',
'account_short' => '新设财务公司农行',
'account_type' => '公户',
'region' => '上海',
'account_no' => '6222000000001234',
'account_last4' => '1234',
'company_plan' => '待注销',
'account_should_keep' => '否',
'current_balance' => 12345.67,
'daily_available' => 0,
'blocked_amount' => 12345.67,
], 'business_mirror');
$this->assertDatabaseHas('fin_account_statuses', [
'account_short' => '新设财务公司农行',
'status' => 'limited',
'updated_by' => 'Test User',
], 'business_mirror');
$this->assertDatabaseHas('fin_account_opening', [
'account_short' => '新设财务公司农行',
'opening_date' => '2026-10-20',
'opening_balance' => 12345.67,
], 'business_mirror');
$this->assertDatabaseHas('fin_daily_flows', [
'company_name' => '新设财务公司',
'account_short' => '新设财务公司农行',
'flow_date' => '2026-10-20',
'opening_balance' => 12345.67,
'closing_balance' => 12345.67,
], 'business_mirror');
$this->loginAs()->get($this->pageUrl() . '?accounts_short=' . urlencode('新设财务公司农行'))
->assertOk()
->assertSee('新设财务公司')
->assertSee('新设财务公司农行')
->assertSee('>1.23</td>', false);
$this->withExceptionHandling()->loginAs()->postJson($this->createAccountUrl(), [
'company_name' => '另一个公司',
'account_short' => '新设财务公司农行',
'status' => 'normal',
'opening_date' => '2026-10-20',
'opening_balance' => 0,
])->assertUnprocessable()->assertJsonValidationErrors('account_short');
$this->assertDatabaseCount('fin_companies', 1, 'business_mirror');
}
public function test_existing_company_profile_can_be_updated_with_a_new_account(): void
{
DB::connection('business_mirror')->table('fin_companies')->insert([
'name' => '已有资料公司',
'register_place' => '江苏省苏州市',
'business_type' => '个体工商户',
'tax_type' => '小规模纳税人',
'status' => '存续',
'remark' => '原公司备注',
]);
$this->loginAs()->postJson($this->createAccountUrl(), [
'company_name' => '已有资料公司',
'company_business_type' => '有限责任公司',
'company_tax_type' => '一般纳税人',
'company_status' => '正常',
'company_basic_bank' => '中国工商银行苏州分行',
'company_basic_account_last4' => '8899',
'company_remark' => '更新后的公司备注',
'account_short' => '已有资料公司工行',
'account_type' => '公户',
'status' => 'normal',
'account_no' => '1102000000008899',
'opening_date' => '2026-10-20',
'opening_balance' => 0,
])
->assertCreated()
->assertJsonPath('account.company_created', false)
->assertJsonPath('account.company_updated', true)
->assertJsonPath('message', '已新增账户并更新公司资料。');
$this->assertDatabaseHas('fin_companies', [
'name' => '已有资料公司',
'register_place' => '江苏省苏州市',
'business_type' => '有限责任公司',
'tax_type' => '一般纳税人',
'status' => '正常',
'basic_bank' => '中国工商银行苏州分行',
'basic_account_last4' => '8899',
'remark' => '更新后的公司备注',
], 'business_mirror');
}
public function test_invalid_status_or_account_is_rejected(): void
{
$this->withExceptionHandling()->loginAs()->postJson($this->statusUrl(), [
@ -771,7 +922,8 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase
->assertSee('data-ledger-edit-mode-toggle', false)
->assertSee('当前账号没有财务台账编辑权限')
->assertDontSee('data-account-status data-account-short', false)
->assertDontSee('data-account-type data-account-short', false);
->assertDontSee('data-account-type data-account-short', false)
->assertDontSee('class="ledger-btn ledger-add-company"', false);
$this->withExceptionHandling()->loginAs($viewer)->postJson($this->statusUrl(), [
'account_short' => '测试账户',
@ -785,6 +937,13 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase
'account_short' => '测试账户',
'account_should_keep' => '否',
])->assertForbidden();
$this->withExceptionHandling()->loginAs($viewer)->postJson($this->createAccountUrl(), [
'company_name' => '无权限公司',
'account_short' => '无权限账户',
'status' => 'normal',
'opening_date' => '2026-10-20',
'opening_balance' => 0,
])->assertForbidden();
$this->withExceptionHandling()->loginAs($viewer)->post($this->accountTodoUrl(), [
'account_short' => '测试账户',
'current_progress' => '处理中',

View File

@ -0,0 +1,89 @@
<?php
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use App\Models\Auth\Role;
use App\Models\Auth\User;
use App\Utilities\SidebarPermissions;
use Tests\Feature\FeatureTestCase;
class LaborExpensePageAccessTest extends FeatureTestCase
{
public function test_labor_expense_page_is_visible_only_to_the_named_accounts(): void
{
$chenxi = $this->account('晨曦');
$jiangling = $this->account('江陵');
$other = $this->account('其他财务');
$this->assertTrue($this->pageIsVisible($chenxi));
$this->assertTrue($this->pageIsVisible($jiangling));
$this->assertFalse($this->pageIsVisible($other));
$this->assertFalse($this->pageIsVisible($this->user));
}
public function test_explicit_page_permission_cannot_bypass_the_viewer_allowlist(): void
{
$other = $this->account('其他财务');
$subject = 'user_' . $other->id;
setting()->set('system.sidebar_permissions', [
'configured' => [$subject],
'subjects' => [
$subject => ['financial-operations/labor-expenses'],
],
]);
$this->assertFalse($this->pageIsVisible($other));
}
public function test_direct_page_and_generic_actions_use_the_same_allowlist(): void
{
$chenxi = $this->account('晨曦');
$other = $this->account('其他财务');
$routeParameters = [
'section' => 'financial-operations',
'page' => 'labor-expenses',
];
$this->loginAs($chenxi)
->get(route('business.pages.show', $routeParameters))
->assertOk();
$this->withExceptionHandling()->loginAs($other)
->get(route('business.pages.show', $routeParameters))
->assertForbidden();
$this->post(route('business.pages.store', $routeParameters), [])
->assertForbidden();
$this->withExceptionHandling()->loginAs($this->user)
->get(route('business.pages.show', $routeParameters))
->assertForbidden();
}
private function pageIsVisible(User $user): bool
{
$financialOperations = collect(SidebarPermissions::navigationFor($user))
->firstWhere('slug', 'financial-operations');
return collect($financialOperations['pages'] ?? [])
->contains('slug', 'labor-expenses');
}
private function account(string $name): User
{
$suffix = User::count();
$user = User::create([
'name' => $name,
'email' => 'labor-access-' . $suffix . '@example.test',
'password' => 'fixture-unused-password',
'enabled' => true,
'locale' => 'zh-CN',
'landing_page' => 'dashboard',
]);
$user->roles()->attach(Role::where('name', 'user')->firstOrFail()->id);
$user->permissions()->attach(Permission::where('name', 'read-admin-panel')->firstOrFail()->id);
$user->companies()->attach($this->company->id);
return $user;
}
}

View File

@ -5,12 +5,14 @@ namespace Tests\Feature\Business;
use App\Business\UpstreamBusinessInformation;
use App\Business\StoreReceivingAccountSynchronizer;
use App\Models\Auth\Permission;
use App\Models\Business\InstallationDetail;
use App\Models\Business\StoreReceivingAccount;
use App\Utilities\BusinessNavigation;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Mockery;
use Modules\EcommerceFinance\Repositories\InstallationDetailMirrorRepository;
use Modules\EcommerceFinance\Repositories\JushuitanShopBalanceRepository;
use Tests\Feature\FeatureTestCase;
@ -220,6 +222,172 @@ class ManagementCrudTest extends FeatureTestCase
->assertSee('value="提现测试主体" selected', false);
}
public function testInstallationDetailsRendersWorkbookHeadersAndImportedRows(): void
{
$mirror = Mockery::mock(InstallationDetailMirrorRepository::class);
$mirror->shouldReceive('paginate')->once()->andReturn([
'ready' => true, 'total' => 1, 'data' => [array_replace(array_fill_keys(array_keys(InstallationDetail::columnLabels()), null), [
'id' => 1, 'installation_no' => 'INSTALL-TEST-001', 'installation_company' => '测试安装公司',
'store_name' => '测试店铺', 'requested_amount' => '120.50', 'settled_amount' => '100.00', 'item_quantity' => 2,
'related_guid' => 'guid-install-test',
])],
]);
$this->app->instance(InstallationDetailMirrorRepository::class, $mirror);
$this->loginAs()
->get(route('business.pages.show', [
'section' => 'financial-operations',
'page' => 'installation-details',
]))
->assertOk()
->assertSeeText('安装明细 · 数据列表')
->assertSeeText('安装单号')
->assertSeeText('申请总金额')
->assertSeeText('商品名称(1)')
->assertSeeText('创建日期:')
->assertSeeText('客服审核时间:')
->assertSeeText('财务审核时间:')
->assertSeeText('推送状态:')
->assertSeeText('选择店铺:')
->assertSeeText('选择删除:')
->assertSeeText('订单类型:')
->assertSeeText('商品ID:')
->assertSeeText('导出')
->assertSee('id="installationFilterForm"', false)
->assertSee('name="created_from"', false)
->assertSee('name="created_to"', false)
->assertSee('name="customer_review_from"', false)
->assertSee('name="customer_review_to"', false)
->assertSee('name="finance_review_from"', false)
->assertSee('name="finance_review_to"', false)
->assertSee('data-date-picker', false)
->assertSee('data-date-calendar', false)
->assertSee("window.CaiWuDatePicker.setup(form)", false)
->assertSee('结束日期不能早于开始日期。')
->assertSee('data-installation-fee-open', false)
->assertSee('id="installation-fee-dialog"', false)
->assertSeeText('安装费金额')
->assertSeeText('按客服审核时间汇总安装明细的结算明细金额')
->assertSeeText('平台')
->assertSeeText('店铺名称')
->assertSeeText('安装公司')
->assertSeeText('收款渠道')
->assertSeeText('结算明细金额')
->assertSee('data-installation-fee-platform', false)
->assertSee('data-installation-fee-platform-rows', false)
->assertSee('refresh(true)', false)
->assertSee("dispatchEvent(new Event('change'", false)
->assertSee('installation-details-table-wrap', false)
->assertSee('installation-details-table', false)
->assertSee('writing-mode: horizontal-tb', false)
->assertSee('text-overflow: ellipsis', false)
->assertSeeText('INSTALL-TEST-001')
->assertSeeText('测试安装公司')
->assertSeeText('120.50')
->assertSeeText('共 1 条');
}
public function testInstallationDetailsExportUsesFiltersAndMirrorRows(): void
{
$row = array_fill_keys(array_keys(InstallationDetail::columnLabels()), null);
$row['installation_no'] = 'INSTALL-EXPORT-001';
$mirror = Mockery::mock(InstallationDetailMirrorRepository::class);
$mirror->shouldReceive('paginate')->once()->with(1, 100, ['created_from' => '2026-08-01'])->andReturn([
'ready' => true,
'total' => 1,
'data' => [$row],
]);
$this->app->instance(InstallationDetailMirrorRepository::class, $mirror);
$response = $this->loginAs()->get(route('business.installation-details.export', [
'created_from' => '2026-08-01',
]));
$response->assertOk()->assertHeader('content-type', 'text/csv; charset=UTF-8');
$this->assertStringContainsString('INSTALL-EXPORT-001', $response->streamedContent());
}
public function testInstallationDetailsEndpointReturnsChildItemsForMainRecord(): void
{
$mirror = Mockery::mock(InstallationDetailMirrorRepository::class);
$mirror->shouldReceive('details')->once()->with('guid-install-test')->andReturn([
'ready' => true,
'data' => [[
'transaction_no' => 'INSTALL-TEST-001',
'product_id' => '10035070813217',
'product_name' => '测试商品',
'detail_requested_amount' => '53.59',
'detail_requested_quantity' => 1,
]],
]);
$this->app->instance(InstallationDetailMirrorRepository::class, $mirror);
$this->loginAs()
->get(route('business.installation-details.details', ['guid' => 'guid-install-test']))
->assertOk()
->assertJsonPath('data.0.transaction_no', 'INSTALL-TEST-001')
->assertJsonPath('data.0.product_name', '测试商品');
}
public function testInstallationSummaryEndpointReturnsFeeRowsForCustomerReviewRange(): void
{
$mirror = Mockery::mock(InstallationDetailMirrorRepository::class);
$mirror->shouldReceive('feeDetails')->once()->with([
'customer_review_from' => '2026-09-01',
'customer_review_to' => '2026-09-30',
])->andReturn([
'ready' => true,
'total' => 2000.50,
'rows' => [
['platform' => '淘系', 'store_name' => '店铺A', 'installation_company' => '万师傅', 'payment_channel' => '包安装', 'amount' => 1250.50],
['platform' => '京东', 'store_name' => '店铺B', 'installation_company' => '鲁班到家', 'payment_channel' => '不包安装', 'amount' => 750.00],
],
]);
$this->app->instance(InstallationDetailMirrorRepository::class, $mirror);
$this->loginAs()
->getJson(route('business.installation-details.summary', [
'customer_review_from' => '2026-09-01',
'customer_review_to' => '2026-09-30',
]))
->assertOk()
->assertJsonPath('total', 2000.5)
->assertJsonPath('rows.0.platform', '淘系')
->assertJsonPath('rows.0.store_name', '店铺A')
->assertJsonPath('rows.0.payment_channel', '包安装')
->assertJsonPath('rows.1.payment_channel', '不包安装');
}
public function testInstallationSummaryRequiresCompleteCustomerReviewRange(): void
{
$mirror = Mockery::mock(InstallationDetailMirrorRepository::class);
$mirror->shouldNotReceive('feeDetails');
$this->app->instance(InstallationDetailMirrorRepository::class, $mirror);
$this->loginAs()
->getJson(route('business.installation-details.summary', [
'customer_review_from' => '2026-09-01',
]))
->assertUnprocessable()
->assertJsonPath('message', '请选择完整的客服审核起止日期');
}
public function testInstallationChildEndpointRequiresTheSameSidebarPermission(): void
{
$this->withExceptionHandling();
$this->user->roles()->sync([\App\Models\Auth\Role::where('name', 'admin')->firstOrFail()->id]);
setting()->set('system.sidebar_permissions', ['configured'=>['user_'.$this->user->id], 'subjects'=>['user_'.$this->user->id=>['store-management/basic-information']]]);
$mirror=Mockery::mock(InstallationDetailMirrorRepository::class);
$mirror->shouldNotReceive('details');
$mirror->shouldNotReceive('feeDetails');
$this->app->instance(InstallationDetailMirrorRepository::class,$mirror);
$this->loginAs()->getJson(route('business.installation-details.details',['guid'=>'guid-install-test']))->assertForbidden();
$this->loginAs()->getJson(route('business.installation-details.summary', [
'customer_review_from' => '2026-09-01',
'customer_review_to' => '2026-09-30',
]))->assertForbidden();
}
public function testCompanyInformationUsesWorkbookHeadersAndReadOnlyUpstreamRows(): void
{
$this->mockUpstreamPage((object) [

View File

@ -165,84 +165,6 @@ class PerformanceDetailsMetricDetailsTest extends FeatureTestCase
], $response->json('rows'));
}
public function test_installation_fee_metric_uses_shipment_month_and_channel_category(): void
{
$mirror = DB::connection('business_mirror');
foreach ([
'jiansun.dbo.fys_Ticketing_install_view',
'jiansun.dbo.fys_Ticketing_install_item',
] as $dataset) {
$mirror->table('business_mirror_table_states')->insert([
'dataset_key' => $dataset,
'initial_completed_at' => '2026-09-01 00:00:00',
]);
}
$mirror->table('js_fys_ticketing_install_view')->insert([
[
'Id' => 1,
'SourceGuid' => 'install-a',
'ShipTIme' => '2026-09-10 12:00:00',
'shop_name' => '店铺A',
'installCompanyName' => '万师傅',
'costWay' => null,
],
[
'Id' => 2,
'SourceGuid' => 'install-b',
'ShipTIme' => '2026-09-11 12:00:00',
'shop_name' => '店铺A',
'installCompanyName' => '万师傅',
'costWay' => '银行转账',
],
[
'Id' => 3,
'SourceGuid' => 'install-old',
'ShipTIme' => '2026-08-31 23:59:59',
'shop_name' => '店铺旧',
'installCompanyName' => '鲁班到家',
'costWay' => null,
],
]);
$mirror->table('js_fys_ticketing_install_item')->insert([
['Id' => 1, 'guid' => 'install-a', 'settleItemAmount' => 120],
['Id' => 2, 'guid' => 'install-b', 'settleItemAmount' => 80],
['Id' => 3, 'guid' => 'install-old', 'settleItemAmount' => 999],
]);
$response = $this->loginAs()->getJson(route(
'business.financial-operations.performance-details.installation-fee-details',
['month' => '2026-09'],
));
$response
->assertOk()
->assertJsonPath('label', '安装费金额')
->assertJsonPath('month', '2026-09')
->assertJsonPath('total', 200)
->assertJsonPath('rows.0.store_name', '店铺A')
->assertJsonPath('rows.0.installation_company', '万师傅')
->assertJsonPath('rows.0.payment_channel', '包安装')
->assertJsonPath('rows.0.amount', 120)
->assertJsonPath('rows.1.payment_channel', '不包安装')
->assertJsonPath('rows.1.amount', 80);
$this->loginAs()
->get(route('business.pages.show', [
'section' => 'financial-operations',
'page' => 'performance-details',
'month' => '2026-09',
]))
->assertOk()
->assertSeeText('安装费金额')
->assertSee('data-installation-fee-open', false)
->assertSee('id="installation-fee-dialog"', false)
->assertSeeText('店铺名称')
->assertSeeText('安装公司')
->assertSeeText('收款渠道')
->assertSeeText('结算明细金额')
->assertSee('installation-fee-details', false);
}
private function row(string $rowKey, string $storeName, float $offset, string $platform = '京东账单'): array
{
return [
@ -292,23 +214,6 @@ class PerformanceDetailsMetricDetailsTest extends FeatureTestCase
private function createMirrorTables(): void
{
$schema = Schema::connection('business_mirror');
$schema->create('business_mirror_table_states', function (Blueprint $table): void {
$table->string('dataset_key')->primary();
$table->dateTime('initial_completed_at')->nullable();
});
$schema->create('js_fys_ticketing_install_view', function (Blueprint $table): void {
$table->integer('Id');
$table->string('SourceGuid');
$table->dateTime('ShipTIme')->nullable();
$table->string('shop_name')->nullable();
$table->string('installCompanyName')->nullable();
$table->string('costWay')->nullable();
});
$schema->create('js_fys_ticketing_install_item', function (Blueprint $table): void {
$table->integer('Id');
$table->string('guid');
$table->decimal('settleItemAmount', 18, 4)->nullable();
});
$schema->create('store_receipt_order_match_rows', function (Blueprint $table): void {
$table->date('report_month');
$table->string('platform');

View File

@ -28,7 +28,9 @@ class SystemizationTodosTest extends FeatureTestCase
->assertSeeText('从 Excel 同步待办')
->assertSeeText('总负责人')
->assertSeeText('责任人')
->assertSeeText('类型内序号')
->assertSeeText('类型序号')
->assertSeeText('内容序号')
->assertSeeText('待办序号')
->assertSeeText('已逾期')
->assertSeeText('开始时间')
->assertSeeText('完成时间')
@ -189,10 +191,11 @@ class SystemizationTodosTest extends FeatureTestCase
$pageResponse
->assertOk()
->assertSee('rowspan="2"', false)
->assertSee('<th scope="col">内容</th><th scope="col">序号</th><th scope="col">待办事项</th>', false)
->assertSee('<th scope="col">内容</th><th scope="col">待办序号</th><th scope="col">待办事项</th>', false)
->assertSee('sys-todo-table__type', false)
->assertSee('title="当前类型内序号">1</span>', false)
->assertSee('title="当前类型内序号">2</span>', false)
->assertSee('title="类型序号">1</span>', false)
->assertSee('title="内容序号">1</span>', false)
->assertSee('title="当前内容内待办序号">2</span>', false)
->assertDontSee('1、平台收入核对')
->assertDontSee('3、退款核销')
->assertDontSee('1、收入事项 A')
@ -206,17 +209,25 @@ class SystemizationTodosTest extends FeatureTestCase
);
$pageItems = $pageContent['items'];
$this->assertSame(
[1, 2],
[1, 1],
$pageItems->where('category', '收入')
->pluck('category_sequence')
->pluck('type_sequence')
->map(static fn (mixed $value): int => (int) $value)
->values()
->all(),
);
$this->assertSame(
[1],
[1, 2],
$pageItems->where('category', '收入')
->pluck('item_sequence')
->map(static fn (mixed $value): int => (int) $value)
->values()
->all(),
);
$this->assertSame(
[2],
$pageItems->where('category', '成本')
->pluck('category_sequence')
->pluck('type_sequence')
->map(static fn (mixed $value): int => (int) $value)
->values()
->all(),
@ -226,11 +237,18 @@ class SystemizationTodosTest extends FeatureTestCase
'section' => 'other',
'page' => 'systemization-todos',
'category' => '收入',
'sequence' => 2,
'item_sequence' => 2,
]))->assertOk();
$sequenceContent = $sequenceResponse->viewData('content');
$this->assertSame(['2、收入事项 B'], $sequenceContent['items']->pluck('title')->all());
$typeSequenceContent = $this->get(route('business.pages.show', [
'section' => 'other',
'page' => 'systemization-todos',
'type_sequence' => 2,
]))->assertOk()->viewData('content');
$this->assertSame(['3、成本事项'], $typeSequenceContent['items']->pluck('title')->all());
$highlightedTodo = $pageItems->firstWhere('title', '2、收入事项 B');
$this->get(route('business.pages.show', [
'section' => 'other',
@ -285,6 +303,65 @@ class SystemizationTodosTest extends FeatureTestCase
);
}
public function test_same_content_stays_merged_and_receives_scoped_hierarchy_sequences(): void
{
$this->loginAs();
$pending = $this->createTodo('收入', '钉钉流程', '待办事项 A', '2026-10-30', 1);
$completed = $this->createTodo('收入', '钉钉流程', '待办事项 B', '2026-10-10', 2);
$completed->update([
'status' => SystemizationTodo::STATUS_COMPLETED,
'completed_at' => '2026-10-10',
]);
$inProgress = $this->createTodo('收入', '钉钉流程', '待办事项 C', '2026-10-20', 3);
$inProgress->update(['status' => SystemizationTodo::STATUS_IN_PROGRESS]);
$secondContent = $this->createTodo('收入', '报销流程', '待办事项 D', '2026-11-01', 4);
$cost = $this->createTodo('成本', '成本核对', '待办事项 E', '2026-11-02', 5);
$response = $this->get(route('business.pages.show', [
'section' => 'other',
'page' => 'systemization-todos',
]))->assertOk();
$content = $response->viewData('content');
$items = $content['items'];
$this->assertSame(
[$pending->id, $completed->id, $inProgress->id, $secondContent->id, $cost->id],
$items->pluck('id')->all(),
);
$this->assertSame(
[[1, 1, [1, 2, 3]], [1, 2, [1]], [2, 1, [1]]],
$items
->groupBy(static fn (SystemizationTodo $todo): string => $todo->type_sequence . ':' . $todo->content_sequence)
->map(static fn ($group): array => [
(int) $group->first()->type_sequence,
(int) $group->first()->content_sequence,
$group->pluck('item_sequence')->map(static fn (mixed $value): int => (int) $value)->all(),
])
->values()
->all(),
);
$contentResponse = $this->get(route('business.pages.show', [
'section' => 'other',
'page' => 'systemization-todos',
'type_sequence' => 1,
'content_sequence' => 2,
]))->assertOk();
$this->assertSame(
[$secondContent->id],
$contentResponse->viewData('content')['items']->pluck('id')->all(),
);
$this->assertSame(
[$completed->id, $inProgress->id],
$items
->where('section', '钉钉流程')
->whereIn('status', [SystemizationTodo::STATUS_COMPLETED, SystemizationTodo::STATUS_IN_PROGRESS])
->pluck('id')
->all(),
);
$this->assertSame(1, substr_count((string) $response->getContent(), '>钉钉流程<'));
}
public function test_page_renders_all_todos_on_one_continuous_list(): void
{
$this->loginAs();

View File

@ -113,6 +113,7 @@ class NavigationTest extends FeatureTestCase
->assertSee('minimumFractionDigits: 2', false)
->assertSee('maximumFractionDigits: 2', false)
->assertDontSeeText('查看完整字段与差额判定规则')
->assertDontSee('data-installation-fee-open', false)
->assertDontSee('receipt-method-guide', false)
->assertDontSee('receipt-match-summary', false)
->assertDontSee('data-column-key="product_amount"', false)
@ -134,21 +135,6 @@ class NavigationTest extends FeatureTestCase
->assertSeeText('店铺收款匹配订单明细尚未同步,请先执行 store-receipt:order-match:sync。');
}
public function testStandaloneInstallationDetailsPageIsRemoved(): void
{
$this->assertFalse(\Illuminate\Support\Facades\Route::has('business.installation-details.details'));
$this->assertFalse(\Illuminate\Support\Facades\Route::has('business.installation-details.summary'));
$this->assertFalse(\Illuminate\Support\Facades\Route::has('business.installation-details.export'));
$this->withExceptionHandling();
$this->loginAs()
->get(route('business.pages.show', [
'section' => 'financial-operations',
'page' => 'installation-details',
]))
->assertRedirect();
}
public function testExpenseDetailsPageIsAvailableAndForcedToExpenseCategory(): void
{
$this->loginAs()

View File

@ -155,4 +155,40 @@ class FinanceLedgerCalculatorTest extends TestCase
$this->assertEqualsWithDelta(500.00, (float) $account->daily_available, 0.001);
$this->assertEqualsWithDelta(0.00, (float) $account->blocked_amount, 0.001);
}
public function test_new_account_uses_its_later_opening_date_and_ignores_earlier_transactions(): void
{
$db = DB::connection('business_mirror');
$db->table('fin_accounts')->insert([
'company_name' => '新设公司',
'account_short' => '新设账户',
'blocked_amount' => 0,
]);
$db->table('fin_account_opening')->insert([
'account_short' => '新设账户',
'opening_date' => '2026-10-20',
'opening_balance' => 700,
]);
$db->table('fin_transactions')->insert([
['txn_date' => '2026-10-18', 'account_short' => '新设账户', 'source' => 'manual', 'source_value' => null, 'income' => 999, 'expense' => 0],
['txn_date' => '2026-10-20', 'account_short' => '新设账户', 'source' => 'manual', 'source_value' => null, 'income' => 200, 'expense' => 0],
]);
app(FinanceLedgerCalculator::class)->recalculate();
$flows = $db->table('fin_daily_flows')
->where('account_short', '新设账户')
->orderBy('flow_date')
->get();
$this->assertCount(1, $flows);
$this->assertSame('2026-10-20', (string) $flows[0]->flow_date);
$this->assertEqualsWithDelta(700.00, (float) $flows[0]->opening_balance, 0.001);
$this->assertEqualsWithDelta(200.00, (float) $flows[0]->inflow, 0.001);
$this->assertEqualsWithDelta(900.00, (float) $flows[0]->closing_balance, 0.001);
$this->assertEqualsWithDelta(
900.00,
(float) $db->table('fin_accounts')->where('account_short', '新设账户')->value('current_balance'),
0.001,
);
}
}

View File

@ -0,0 +1,224 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
use Modules\EcommerceFinance\Repositories\InstallationDetailMirrorRepository;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use Tests\TestCase;
class InstallationDetailMirrorRepositoryTest extends TestCase
{
private InstallationDetailMirrorRepository $repository;
protected function setUp(): void
{
parent::setUp();
config(['database.connections.business_mirror'=>['driver'=>'sqlite','database'=>':memory:','prefix'=>''], 'upstream_mirrors.connection'=>'business_mirror']);
DB::purge('business_mirror');
$definitions=[];
foreach ([InstallationDetailMirrorRepository::DATASET=>'fixture_install',InstallationDetailMirrorRepository::ITEM_DATASET=>'fixture_items'] as $key=>$table) {
$definitions[$key]=['source_key'=>'jiansun','schema'=>'dbo','object'=>explode('.',$key)[2],'target'=>$table,'keys'=>['Id']];
}
$definitions[InstallationDetailMirrorRepository::DATASET]['minimum_datetime'] = [
'column' => 'ShipTIme',
'value' => '2026-01-01 00:00:00',
];
config(['upstream_mirrors.datasets'=>$definitions]);
$schema=Schema::connection('business_mirror');
$schema->create('business_mirror_table_states',function(Blueprint $t){$t->string('dataset_key')->primary();$t->dateTime('initial_completed_at')->nullable();});
$schema->create('fixture_install',function(Blueprint $t){
$t->integer('Id');
foreach(['SourceGuid','OrderNum','ordernum_id','shop_name','SubmitTime','ShipTIme','kfshTime','cwshTime','apistatus','kfsh','cwsh','BidStatus','costtaker','costWay','installComplete','Delete','installCompanyName','TypeDto'] as $c) $t->string($c)->nullable();
$t->integer('StatusId')->nullable()->default(0);
foreach(['installcost','settleAmount','acceptAmount'] as $c) $t->decimal($c,14,2)->default(0);
});
$schema->create('fixture_items',function(Blueprint $t){
$t->integer('Id');
$t->string('guid');
$t->string('shopproid');
$t->string('Sourceno')->nullable();
$t->dateTime('kfshTime')->nullable();
$t->decimal('settleItemAmount',14,2)->default(0);
$t->decimal('acceptItemAmount',14,2)->default(0);
});
foreach(array_keys($definitions) as $key) DB::connection('business_mirror')->table('business_mirror_table_states')->insert(['dataset_key'=>$key,'initial_completed_at'=>'2026-08-01']);
DB::connection('business_mirror')->table('fixture_install')->insert([
['Id'=>1,'SourceGuid'=>'A','OrderNum'=>'OLD','shop_name'=>'旧店','SubmitTime'=>'2026-08-01','ShipTIme'=>'2026-08-02','installcost'=>500],
['Id'=>2,'SourceGuid'=>'A','OrderNum'=>'NEW','shop_name'=>'测试店','SubmitTime'=>'2026-08-20 23:59:59','ShipTIme'=>'2026-08-21','installcost'=>20],
['Id'=>3,'SourceGuid'=>'B','OrderNum'=>'OTHER','shop_name'=>'其他店','SubmitTime'=>'2026-08-20','ShipTIme'=>'2026-08-21','installcost'=>30],
['Id'=>4,'SourceGuid'=>'C','OrderNum'=>'NEXT','shop_name'=>'测试店','SubmitTime'=>'2026-08-21','ShipTIme'=>'2026-08-22','installcost'=>40],
['Id'=>5,'SourceGuid'=>'D','OrderNum'=>'PRE-2026','shop_name'=>'测试店','SubmitTime'=>'2026-08-21','ShipTIme'=>'2025-12-31 23:59:59','installcost'=>999],
['Id'=>6,'SourceGuid'=>'E','OrderNum'=>'NULL-SHIP','shop_name'=>'测试店','SubmitTime'=>'2026-08-21','ShipTIme'=>null,'installcost'=>999],
]);
DB::connection('business_mirror')->table('fixture_items')->insert([
['Id'=>1,'guid'=>'A','shopproid'=>'PRODUCT-A'],['Id'=>2,'guid'=>'A','shopproid'=>'PRODUCT-A'],
]);
$this->repository=new InstallationDetailMirrorRepository(new GenericBusinessMirrorRepository(app('db')),new BusinessMirrorDatasetRegistry());
}
public function test_filters_apply_to_deduplicated_list_count_and_summary(): void
{
$filters=['shop_name'=>'测试店'];
$page=$this->repository->paginate(2,1,$filters);
$this->assertSame(2,$page['total']);
$this->assertSame(2,$page['data'][0]['id']);
$this->assertSame(60.0,$this->repository->summary($filters)['requested']);
$filters['created_to']='2026-08-20';
$this->assertSame(1,$this->repository->paginate(1,50,$filters)['total']);
$this->assertSame(20.0,$this->repository->summary($filters)['requested']);
$this->assertSame(0,$this->repository->paginate(1,50,['shop_name'=>"' OR 1=1 --"])['total']);
}
public function test_product_filter_does_not_multiply_main_rows(): void
{
$filters=['product_id'=>'PRODUCT-A'];
$this->assertSame(1,$this->repository->paginate(1,50,$filters)['total']);
$this->assertSame(20.0,$this->repository->summary($filters)['requested']);
$this->assertSame('NEW',$this->repository->details('A')['data'][0]['transaction_no']);
$this->assertSame([],$this->repository->details('missing')['data']);
}
public function test_shipment_cutoff_hides_old_and_null_records_from_list_summary_and_details(): void
{
DB::connection('business_mirror')->table('fixture_items')->insert([
['Id'=>30,'guid'=>'D','shopproid'=>'OLD-PRODUCT'],
['Id'=>31,'guid'=>'E','shopproid'=>'NULL-PRODUCT'],
]);
$this->assertSame(3,$this->repository->paginate(1,50)['total']);
$this->assertSame(3,$this->repository->summary()['rows']);
$this->assertSame([],$this->repository->details('D')['data']);
$this->assertSame([],$this->repository->details('E')['data']);
}
public function test_status_filters_and_child_limit_are_explicit(): void
{
DB::connection('business_mirror')->table('fixture_install')->where('Id',2)->update(['kfsh'=>'1','Delete'=>'0','StatusId'=>2]);
$this->assertSame(1,$this->repository->paginate(1,50,['customer_review'=>'1','delete_status'=>'0','arranged_status'=>'1'])['total']);
$rows=[];for($i=1;$i<=101;$i++)$rows[]=['Id'=>$i+10,'guid'=>'B','shopproid'=>'P'.$i];
DB::connection('business_mirror')->table('fixture_items')->insert($rows);
$result=$this->repository->details('B');
$this->assertCount(100,$result['data']);
$this->assertTrue($result['has_more']);
}
public function test_unset_status_filters_include_null_values_and_render_explicit_labels(): void
{
DB::connection('business_mirror')->table('fixture_install')->where('Id',3)->update(['StatusId'=>null]);
foreach (['customer_review','finance_review','completed_status','delete_status','arranged_status'] as $key) {
$this->assertSame(3,$this->repository->paginate(1,50,[$key=>'0'])['total'],$key);
}
$other=$this->repository->paginate(1,50,['shop_name'=>'其他店'])['data'][0];
$this->assertSame('未审核',$other['customer_service_review']);
$this->assertSame('未审核',$other['finance_review']);
$this->assertSame('否',$other['completed_status']);
$this->assertSame('未安排',$other['arranged_status']);
DB::connection('business_mirror')->table('fixture_install')->where('Id',2)->update([
'kfsh'=>1,
'cwsh'=>1,
'installComplete'=>1,
'Delete'=>0,
'StatusId'=>1,
]);
foreach (['customer_review','finance_review','completed_status','arranged_status'] as $key) {
$this->assertSame(1,$this->repository->paginate(1,50,[$key=>'1'])['total'],$key);
}
}
public function test_fee_details_use_customer_review_time_and_latest_main_channel(): void
{
DB::connection('business_mirror')->table('fixture_install')->where('Id',1)->update(['installCompanyName'=>'旧公司']);
DB::connection('business_mirror')->table('fixture_install')->where('Id',2)->update(['installCompanyName'=>'万师傅','costWay'=>null]);
DB::connection('business_mirror')->table('fixture_install')->where('Id',3)->update(['installCompanyName'=>'鲁班到家','costWay'=>'银行转账']);
DB::connection('business_mirror')->table('fixture_install')->where('Id',4)->update(['installCompanyName'=>'万师傅']);
DB::connection('business_mirror')->table('fixture_items')->insert([
['Id'=>20,'guid'=>'A','shopproid'=>'P1','Sourceno'=>'A-1','kfshTime'=>'2026-09-01 09:00:00','settleItemAmount'=>60.10,'acceptItemAmount'=>40.00],
['Id'=>21,'guid'=>'A','shopproid'=>'P2','Sourceno'=>'A-1','kfshTime'=>'2026-09-30 18:00:00','settleItemAmount'=>39.90,'acceptItemAmount'=>0.00],
['Id'=>22,'guid'=>'B','shopproid'=>'P3','Sourceno'=>'B-1','kfshTime'=>'2026-09-15 10:00:00','settleItemAmount'=>80.00,'acceptItemAmount'=>70.00],
['Id'=>23,'guid'=>'C','shopproid'=>'P4','Sourceno'=>'C-1','kfshTime'=>'2026-10-01 00:00:00','settleItemAmount'=>500.00,'acceptItemAmount'=>500.00],
['Id'=>24,'guid'=>'D','shopproid'=>'OLD','Sourceno'=>'D-1','kfshTime'=>'2026-09-10 10:00:00','settleItemAmount'=>999.00,'acceptItemAmount'=>999.00],
['Id'=>25,'guid'=>'E','shopproid'=>'NULL','Sourceno'=>'E-1','kfshTime'=>'2026-09-10 10:00:00','settleItemAmount'=>999.00,'acceptItemAmount'=>999.00],
]);
$result=$this->repository->feeDetails([
'customer_review_from'=>'2026-09-01',
'customer_review_to'=>'2026-09-30',
]);
$this->assertTrue($result['ready']);
$this->assertSame([
['platform'=>'其他','store_name'=>'测试店','installation_company'=>'万师傅','payment_channel'=>'包安装','amount'=>100.0],
['platform'=>'其他','store_name'=>'其他店','installation_company'=>'鲁班到家','payment_channel'=>'不包安装','amount'=>80.0],
],$result['rows']);
$this->assertSame(180.0,$result['total']);
}
public function test_fee_details_classifies_platform_by_store_name_rules(): void
{
$stores = [
'阿里巴巴-测试店' => '淘系',
'天猫-测试店' => '淘系',
'新佰广1688' => '淘系',
'淘工厂-测试店' => '淘系',
'淘宝-测试店' => '淘系',
'抖音-测试店' => '抖音',
'拼多多-测试店' => '拼多多',
'唯品会-测试店' => '唯品会',
'京东-测试店' => '京东',
'京喜-测试店' => '京喜',
'其他平台店铺' => '其他',
];
$mainRows = [];
$itemRows = [];
$id = 100;
foreach (array_keys($stores) as $store) {
$guid = 'platform-' . $id;
$mainRows[] = [
'Id' => $id,
'SourceGuid' => $guid,
'OrderNum' => 'ORDER-' . $id,
'shop_name' => $store,
'SubmitTime' => '2026-09-01',
'ShipTIme' => '2026-09-02',
'installCompanyName' => '万师傅',
'costWay' => null,
];
$itemRows[] = [
'Id' => $id,
'guid' => $guid,
'shopproid' => 'PRODUCT-' . $id,
'kfshTime' => '2026-09-15 10:00:00',
'settleItemAmount' => 1,
];
$id++;
}
DB::connection('business_mirror')->table('fixture_install')->insert($mainRows);
DB::connection('business_mirror')->table('fixture_items')->insert($itemRows);
$result = $this->repository->feeDetails([
'customer_review_from' => '2026-09-01',
'customer_review_to' => '2026-09-30',
]);
$actual = collect($result['rows'])->pluck('platform', 'store_name')->all();
foreach ($stores as $store => $platform) {
$this->assertSame($platform, $actual[$store] ?? null, $store);
}
}
public function test_end_date_alone_is_valid_and_invalid_status_is_rejected(): void
{
request()->merge(['created_to'=>'2026-08-20']);
$this->assertSame(['created_to'=>'2026-08-20'],InstallationDetailMirrorRepository::requestFilters());
request()->merge(['arranged_status'=>'unexpected']);
$this->expectException(\Illuminate\Validation\ValidationException::class);
InstallationDetailMirrorRepository::requestFilters();
}
}

View File

@ -1,119 +0,0 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
use Modules\EcommerceFinance\Repositories\InstallationFeeSummaryRepository;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use Tests\TestCase;
class InstallationFeeSummaryRepositoryTest extends TestCase
{
private InstallationFeeSummaryRepository $repository;
protected function setUp(): void
{
parent::setUp();
config([
'database.connections.business_mirror' => [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
],
'upstream_mirrors.connection' => 'business_mirror',
'cache.default' => 'array',
'upstream_mirrors.datasets' => [
InstallationFeeSummaryRepository::MAIN_DATASET => [
'source_key' => 'jiansun',
'schema' => 'dbo',
'object' => 'install_view',
'target' => 'fixture_install_view',
'keys' => ['Id'],
],
InstallationFeeSummaryRepository::ITEM_DATASET => [
'source_key' => 'jiansun',
'schema' => 'dbo',
'object' => 'install_item',
'target' => 'fixture_install_item',
'keys' => ['Id'],
],
],
]);
DB::purge('business_mirror');
Cache::flush();
$schema = Schema::connection('business_mirror');
$schema->create('business_mirror_table_states', function (Blueprint $table): void {
$table->string('dataset_key')->primary();
$table->dateTime('initial_completed_at')->nullable();
});
$schema->create('fixture_install_view', function (Blueprint $table): void {
$table->integer('Id');
$table->string('SourceGuid');
$table->dateTime('ShipTIme')->nullable();
$table->string('shop_name')->nullable();
$table->string('installCompanyName')->nullable();
$table->string('costWay')->nullable();
});
$schema->create('fixture_install_item', function (Blueprint $table): void {
$table->integer('Id');
$table->string('guid');
$table->decimal('settleItemAmount', 18, 4)->nullable();
});
foreach ([InstallationFeeSummaryRepository::MAIN_DATASET, InstallationFeeSummaryRepository::ITEM_DATASET] as $dataset) {
DB::connection('business_mirror')->table('business_mirror_table_states')->insert([
'dataset_key' => $dataset,
'initial_completed_at' => '2026-09-01 00:00:00',
]);
}
$mirror = DB::connection('business_mirror');
$mirror->table('fixture_install_view')->insert([
['Id' => 1, 'SourceGuid' => 'A', 'ShipTIme' => '2026-09-01 10:00:00', 'shop_name' => '店铺A', 'installCompanyName' => '万师傅', 'costWay' => null],
['Id' => 2, 'SourceGuid' => 'A', 'ShipTIme' => '2026-09-02 10:00:00', 'shop_name' => '店铺A', 'installCompanyName' => '万师傅', 'costWay' => null],
['Id' => 3, 'SourceGuid' => 'B', 'ShipTIme' => '2026-09-03 10:00:00', 'shop_name' => '店铺A', 'installCompanyName' => '万师傅', 'costWay' => '银行转账'],
['Id' => 4, 'SourceGuid' => 'C', 'ShipTIme' => '2026-08-31 23:59:59', 'shop_name' => '店铺C', 'installCompanyName' => '鲁班到家', 'costWay' => null],
['Id' => 5, 'SourceGuid' => 'D', 'ShipTIme' => '2026-09-04 10:00:00', 'shop_name' => null, 'installCompanyName' => ' ', 'costWay' => ' '],
]);
$mirror->table('fixture_install_item')->insert([
['Id' => 10, 'guid' => 'A', 'settleItemAmount' => 100],
['Id' => 11, 'guid' => 'A', 'settleItemAmount' => 25],
['Id' => 12, 'guid' => 'B', 'settleItemAmount' => 50],
['Id' => 13, 'guid' => 'C', 'settleItemAmount' => 999],
['Id' => 14, 'guid' => 'D', 'settleItemAmount' => 10],
]);
$this->repository = new InstallationFeeSummaryRepository(
new GenericBusinessMirrorRepository(app('db')),
new BusinessMirrorDatasetRegistry(),
);
}
public function test_summary_and_details_use_shipment_month_and_settlement_detail_amount(): void
{
$summary = $this->repository->summary('2026-09');
$this->assertTrue($summary['ready']);
$this->assertSame(185.0, $summary['total']);
$this->assertSame(4, $summary['row_count']);
$details = $this->repository->details('2026-09');
$this->assertTrue($details['ready']);
$this->assertSame(185.0, $details['total']);
$this->assertSame([
['store_name' => '店铺A', 'installation_company' => '万师傅', 'payment_channel' => '包安装', 'amount' => 125.0],
['store_name' => '店铺A', 'installation_company' => '万师傅', 'payment_channel' => '不包安装', 'amount' => 50.0],
['store_name' => '未填写店铺', 'installation_company' => '未填写安装公司', 'payment_channel' => '包安装', 'amount' => 10.0],
], $details['rows']);
}
public function test_invalid_month_is_rejected(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->repository->summary('2026-13');
}
}

View File

@ -158,6 +158,7 @@ class UpstreamBusinessInformationTest extends TestCase
$this->assertSame('正确店铺负责人', $paginator->items()[0]->store_manager);
$this->assertSame('正确部门负责人', $paginator->items()[0]->department_manager);
$this->assertNotSame('运营总监', $paginator->items()[0]->department_manager);
$this->assertSame('正常', $paginator->items()[0]->store_status);
$summaryCards = collect($this->app['request']->attributes->get('business-page-summary-cards'))->keyBy('label');
$this->assertSame(1, $summaryCards['店铺总数']['value']);
$this->assertSame(1, $summaryCards['正常店铺']['value']);