diff --git a/app/Console/Commands/ApplicationReady.php b/app/Console/Commands/ApplicationReady.php index 8f6e5d2a..eac78802 100644 --- a/app/Console/Commands/ApplicationReady.php +++ b/app/Console/Commands/ApplicationReady.php @@ -8,6 +8,12 @@ use Throwable; class ApplicationReady extends Command { + public const EXIT_NOT_INSTALLED = 20; + + public const EXIT_RUNTIME_NOT_WRITABLE = 21; + + public const EXIT_DATABASE_CONNECTION_FAILED = 22; + protected $signature = 'app:ready'; protected $description = '检查应用安装、运行目录和数据库是否已经可用'; @@ -15,30 +21,36 @@ class ApplicationReady extends Command public function handle(): int { if (! config('app.installed')) { - return self::FAILURE; + return $this->failed('APP_INSTALLED 未被 Laravel 加载。', self::EXIT_NOT_INSTALLED); + } + + foreach ([ + storage_path('app'), + storage_path('framework/views'), + storage_path('logs'), + base_path('bootstrap/cache'), + base_path('.env'), + ] as $writablePath) { + if (! is_writable($writablePath)) { + return $this->failed('Laravel 运行目录不可写。', self::EXIT_RUNTIME_NOT_WRITABLE); + } } try { - foreach ([ - storage_path('app'), - storage_path('framework/views'), - storage_path('logs'), - base_path('bootstrap/cache'), - base_path('.env'), - ] as $writablePath) { - if (! is_writable($writablePath)) { - return self::FAILURE; - } - } - DB::selectOne('SELECT 1'); - } catch (Throwable) { - return self::FAILURE; + return $this->failed('Laravel 数据库连接失败。', self::EXIT_DATABASE_CONNECTION_FAILED); } $this->components->info('应用已就绪。'); return self::SUCCESS; } + + private function failed(string $message, int $exitCode): int + { + $this->components->error($message); + + return $exitCode; + } } diff --git a/app/Console/Commands/VerifySystemDatabase.php b/app/Console/Commands/VerifySystemDatabase.php index 19ffbcf6..9af6f293 100644 --- a/app/Console/Commands/VerifySystemDatabase.php +++ b/app/Console/Commands/VerifySystemDatabase.php @@ -5,11 +5,16 @@ namespace App\Console\Commands; use App\Utilities\SystemDatabaseConfiguration; use Illuminate\Console\Command; use Illuminate\Support\Facades\DB; -use RuntimeException; use Throwable; class VerifySystemDatabase extends Command { + public const EXIT_CONFIG_NOT_LOADED = 10; + + public const EXIT_DATABASE_CONNECTION_FAILED = 11; + + public const EXIT_SYSTEM_TABLE_VALIDATION_FAILED = 12; + protected $signature = 'system-database:verify'; protected $description = '在新的 Laravel 进程中验证当前共享系统数据库'; @@ -18,21 +23,53 @@ class VerifySystemDatabase extends Command { try { $settings = $configuration->current(); + } catch (Throwable) { + return $this->failed( + '新配置未被 Laravel 加载。', + self::EXIT_CONFIG_NOT_LOADED + ); + } - if ($settings['mode'] !== 'shared') { - throw new RuntimeException('当前不是共享系统数据库模式。'); - } + if ( + $settings['mode'] !== 'shared' + || ! config('upstreams.stored_configuration.enabled', false) + || config('upstreams.local_configuration.enabled', false) + ) { + return $this->failed( + '新配置未被 Laravel 加载。', + self::EXIT_CONFIG_NOT_LOADED + ); + } + try { DB::purge((string) $settings['connection']); DB::reconnect((string) $settings['connection']); DB::selectOne('SELECT 1'); + } catch (Throwable) { + return $this->failed( + 'Laravel 数据库连接失败。', + self::EXIT_DATABASE_CONNECTION_FAILED + ); + } + + try { $configuration->probe($settings); } catch (Throwable) { - return self::FAILURE; + return $this->failed( + '系统表验证失败。', + self::EXIT_SYSTEM_TABLE_VALIDATION_FAILED + ); } $this->components->info('共享系统数据库已通过应用级验证。'); return self::SUCCESS; } + + private function failed(string $message, int $exitCode): int + { + $this->components->error($message); + + return $exitCode; + } } diff --git a/app/Exceptions/SystemDatabaseVerificationException.php b/app/Exceptions/SystemDatabaseVerificationException.php new file mode 100644 index 00000000..0558d170 --- /dev/null +++ b/app/Exceptions/SystemDatabaseVerificationException.php @@ -0,0 +1,9 @@ + null, 'content' => $content, 'edgeBanding' => $this->edgeBandingContent($page), + 'inventoryData' => $this->inventoryContent($page), + 'rawBoardData' => $this->rawBoardContent($page), ]); } @@ -123,6 +133,8 @@ class Pages extends Controller 'activeTab' => $activeTab, 'content' => $content, 'edgeBanding' => $this->edgeBandingContent($page), + 'inventoryData' => $this->inventoryContent($page), + 'rawBoardData' => $this->rawBoardContent($page), ]); } @@ -235,6 +247,192 @@ class Pages extends Controller return $filters; } + /** + * 素板价格与对账页面使用 JianSun 只读查询,并保留共享快照链路。 + */ + protected function rawBoardContent(string $page): ?array + { + $scope = match ($page) { + 'board-prices' => 'raw-board.prices', + 'board-reconciliation' => 'raw-board.reconciliations', + default => null, + }; + + if ($scope === null) { + return null; + } + + $filters = $scope === 'raw-board.prices' + ? $this->rawBoardFilters(['raw_name', 'raw_code', 'production_code', 'supplier']) + : $this->rawBoardReconciliationFilters(); + $pageNumber = max((int) request('data_page', 1), 1); + $limit = min(max((int) request('data_limit', 25), 1), 100); + + try { + $cache = $this->upstreamCaches->forSource(self::RAW_BOARD_SOURCE); + $result = $cache->remember( + $scope, + ['filters' => $filters, 'page' => $pageNumber, 'limit' => $limit], + fn () => $scope === 'raw-board.prices' + ? $this->rawBoardPayables->prices($filters, $pageNumber, $limit) + : $this->rawBoardPayables->reconciliations($filters, $pageNumber, $limit), + ); + + return [ + 'filters' => $filters, + 'result' => $result, + 'page' => $pageNumber, + 'limit' => $limit, + 'last_page' => max((int) ceil(max((int) ($result['total'] ?? 0), 0) / $limit), 1), + 'status' => $cache->responseHeaders(), + 'error' => null, + ]; + } catch (ServiceUnavailableHttpException $exception) { + return $this->emptyRawBoardContent($filters, $pageNumber, $limit, $exception->getMessage()); + } catch (Throwable $exception) { + report($exception); + + return $this->emptyRawBoardContent( + $filters, + $pageNumber, + $limit, + '素板应付数据暂时无法读取,请稍后重试。', + ); + } + } + + private function rawBoardFilters(array $fields): array + { + $filters = []; + + foreach ($fields as $field) { + $value = mb_substr(trim((string) request($field, '')), 0, 100); + + if ($value !== '') { + $filters[$field] = $value; + } + } + + return $filters; + } + + private function rawBoardReconciliationFilters(): array + { + $filters = $this->rawBoardFilters([ + 'production_code', 'raw_name', 'outbound_supplier', + 'arrival_supplier', 'outbound_no', + ]); + + foreach (['date_from', 'date_to'] as $field) { + $value = $this->dateFilter(request($field)); + + if ($value !== '') { + $filters[$field] = $value; + } + } + + $returnFlag = trim((string) request('return_flag', '')); + + if (in_array($returnFlag, ['normal', 'return'], true)) { + $filters['return_flag'] = $returnFlag; + } + + return $filters; + } + + private function emptyRawBoardContent(array $filters, int $page, int $limit, string $error): array + { + return [ + 'filters' => $filters, + 'result' => ['data' => [], 'total' => 0, 'totals' => []], + 'page' => $page, + 'limit' => $limit, + 'last_page' => 1, + 'status' => [], + 'error' => $error, + ]; + } + + /** + * 板材库存页面使用 JianSun 最新素板快照,并保留生产共享快照链路。 + */ + protected function inventoryContent(string $page): ?array + { + if ($page !== 'board-inventory-details') { + return null; + } + + $filters = $this->inventoryFilters(); + $pageNumber = max((int) request('data_page', 1), 1); + $limit = min(max((int) request('data_limit', 25), 1), 100); + + try { + $cache = $this->upstreamCaches->forSource(self::INVENTORY_SOURCE); + $result = $cache->remember( + 'inventory.board-details', + ['filters' => $filters, 'page' => $pageNumber, 'limit' => $limit], + fn () => $this->inventory->boardInventoryDetails($filters, $pageNumber, $limit), + ); + + return [ + 'filters' => $filters, + 'result' => $result, + 'page' => $pageNumber, + 'limit' => $limit, + 'last_page' => max((int) ceil(max((int) ($result['total'] ?? 0), 0) / $limit), 1), + 'status' => $cache->responseHeaders(), + 'error' => null, + ]; + } catch (ServiceUnavailableHttpException $exception) { + return $this->emptyInventoryContent($filters, $pageNumber, $limit, $exception->getMessage()); + } catch (Throwable $exception) { + report($exception); + + return $this->emptyInventoryContent( + $filters, + $pageNumber, + $limit, + '板材库存数据暂时无法读取,请稍后重试。', + ); + } + } + + private function inventoryFilters(): array + { + $filters = []; + + foreach (['manufacturer', 'specification', 'matching_value'] as $field) { + $value = mb_substr(trim((string) request($field, '')), 0, 100); + + if ($value !== '') { + $filters[$field] = $value; + } + } + + foreach (['date_from', 'date_to'] as $field) { + $value = $this->dateFilter(request($field)); + + if ($value !== '') { + $filters[$field] = $value; + } + } + + return $filters; + } + + private function emptyInventoryContent(array $filters, int $page, int $limit, string $error): array + { + return [ + 'filters' => $filters, + 'result' => ['data' => [], 'total' => 0, 'totals' => []], + 'page' => $page, + 'limit' => $limit, + 'last_page' => 1, + 'status' => [], + 'error' => $error, + ]; + } + /** * 种菜应收对账 - 加载对账数据(21字段) */ diff --git a/app/Utilities/SystemDatabaseConfiguration.php b/app/Utilities/SystemDatabaseConfiguration.php index 5d74c9ad..312100f4 100644 --- a/app/Utilities/SystemDatabaseConfiguration.php +++ b/app/Utilities/SystemDatabaseConfiguration.php @@ -2,11 +2,15 @@ namespace App\Utilities; +use App\Console\Commands\ApplicationReady; +use App\Console\Commands\VerifySystemDatabase; +use App\Exceptions\SystemDatabaseVerificationException; use Dotenv\Dotenv; use Illuminate\Support\Facades\Artisan; use PDO; use PDOException; use RuntimeException; +use Symfony\Component\Process\PhpExecutableFinder; use Symfony\Component\Process\Process; use Throwable; @@ -196,34 +200,41 @@ class SystemDatabaseConfiguration try { if (file_put_contents($this->environmentPath, $updated, LOCK_EX) === false) { - throw new RuntimeException('系统数据库配置写入失败。'); + throw new SystemDatabaseVerificationException('新配置写入失败'); } - Artisan::call('config:clear'); + $this->clearConfigurationCache(); $this->runFreshVerification('system-database:verify'); } catch (Throwable $exception) { $restored = file_put_contents($this->environmentPath, $current, LOCK_EX) !== false; - Artisan::call('config:clear'); + $candidateStage = $this->safeVerificationStage($exception); if (! $restored) { throw new RuntimeException( - '新配置未通过应用级验证,且旧配置自动恢复失败。请立即使用私有备份恢复。', + "新配置未通过应用级验证({$candidateStage}),且旧配置恢复失败。请立即使用私有备份恢复。", 0, $exception ); } try { + $this->clearConfigurationCache(); $this->runFreshVerification('app:ready'); } catch (Throwable $rollbackException) { + $rollbackStage = $this->safeVerificationStage($rollbackException); + throw new RuntimeException( - '新配置未通过应用级验证,旧配置文件已经恢复,但旧连接复核失败。', + "新配置未通过应用级验证({$candidateStage}),旧配置文件已经恢复,但旧配置复核失败({$rollbackStage})。", 0, $rollbackException ); } - throw new RuntimeException('新配置未通过应用级验证,已自动恢复原系统数据库。', 0, $exception); + throw new RuntimeException( + "新配置未通过应用级验证({$candidateStage}),已自动恢复原系统数据库。", + 0, + $exception + ); } return $backupPath; @@ -390,32 +401,109 @@ class SystemDatabaseConfiguration // 新进程便不会读取刚写入 .env 的共享库配置,导致全新环境必然误判 // 为 bootstrap 并回滚。显式移除本次切换涉及的键,让子进程以磁盘 // 上最新(或刚恢复)的 .env 为唯一事实来源。 - $process = new Process( - [PHP_BINARY, base_path('artisan'), $command, '--no-ansi', '--quiet'], - base_path(), - $this->freshVerificationEnvironment() - ); - $process->setTimeout(60); - $process->run(); + $php = $this->phpExecutable(); + + try { + $process = new Process( + [$php, base_path('artisan'), $command, '--no-ansi', '--quiet'], + base_path(), + $this->freshVerificationEnvironment() + ); + $process->setTimeout(60); + $process->run(); + } catch (SystemDatabaseVerificationException $exception) { + throw $exception; + } catch (Throwable $exception) { + throw new SystemDatabaseVerificationException('PHP 验证进程无法启动', 0, $exception); + } if (! $process->isSuccessful()) { - throw new RuntimeException('新的 Laravel 进程未通过应用级数据库验证。'); + throw new SystemDatabaseVerificationException( + $this->verificationExitStage($command, (int) $process->getExitCode()) + ); } } + protected function phpExecutable(): string + { + $binary = DIRECTORY_SEPARATOR === '\\' ? 'php.exe' : 'php'; + $candidates = [ + (new PhpExecutableFinder())->find(false), + PHP_BINARY, + PHP_BINDIR.DIRECTORY_SEPARATOR.$binary, + ]; + + foreach (array_unique($candidates) as $php) { + if (is_string($php) && $php !== '' && is_file($php) && is_executable($php)) { + return $php; + } + } + + throw new SystemDatabaseVerificationException('PHP 验证进程无法启动'); + } + + protected function clearConfigurationCache(): void + { + try { + $exitCode = Artisan::call('config:clear'); + } catch (Throwable $exception) { + throw new SystemDatabaseVerificationException('运行目录不可写', 0, $exception); + } + + if ($exitCode !== 0) { + throw new SystemDatabaseVerificationException('运行目录不可写'); + } + } + + private function verificationExitStage(string $command, int $exitCode): string + { + if ($command === 'system-database:verify') { + return match ($exitCode) { + VerifySystemDatabase::EXIT_CONFIG_NOT_LOADED => '新配置未被加载', + VerifySystemDatabase::EXIT_DATABASE_CONNECTION_FAILED => 'Laravel 数据库连接失败', + VerifySystemDatabase::EXIT_SYSTEM_TABLE_VALIDATION_FAILED => '系统表验证失败', + default => '应用级验证失败', + }; + } + + if ($command === 'app:ready') { + return match ($exitCode) { + ApplicationReady::EXIT_NOT_INSTALLED => 'APP_INSTALLED 未被加载', + ApplicationReady::EXIT_RUNTIME_NOT_WRITABLE => '运行目录不可写', + ApplicationReady::EXIT_DATABASE_CONNECTION_FAILED => 'Laravel 数据库连接失败', + default => '旧配置应用复核失败', + }; + } + + return '应用级验证失败'; + } + + private function safeVerificationStage(Throwable $exception): string + { + return $exception instanceof SystemDatabaseVerificationException + ? $exception->getMessage() + : '应用级验证失败'; + } + /** * @return array */ protected function freshVerificationEnvironment(): array { $keys = [ + 'APP_ENV', + 'APP_INSTALLED', + 'DATABASE_URL', 'DB_CONNECTION', 'DB_HOST', + 'DB_HOST_READ', + 'DB_HOST_WRITE', 'DB_PORT', 'DB_DATABASE', 'DB_USERNAME', 'DB_PASSWORD', 'DB_PREFIX', + 'DB_SOCKET', 'MYSQL_ATTR_SSL_CIPHER', 'MYSQL_ATTR_SSL_CA', 'MYSQL_ATTR_SSL_VERIFY_SERVER_CERT', @@ -427,13 +515,13 @@ class SystemDatabaseConfiguration $contents = file_get_contents($this->environmentPath); if ($contents === false) { - throw new RuntimeException('无法读取刚保存的系统数据库配置。'); + throw new SystemDatabaseVerificationException('新配置未被加载'); } try { $saved = Dotenv::parse($contents); } catch (Throwable $exception) { - throw new RuntimeException('刚保存的系统数据库配置格式无效。', 0, $exception); + throw new SystemDatabaseVerificationException('新配置未被加载', 0, $exception); } foreach ($keys as $key) { diff --git a/config/business_navigation.php b/config/business_navigation.php index 40fafedb..5892cbd5 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -224,7 +224,7 @@ return [ 'title' => '原材料-封边条', 'icon' => 'straighten', 'pages' => [ - ['slug' => 'edge-banding-prices', 'title' => '封标条价格'], + ['slug' => 'edge-banding-prices', 'title' => '封边条价格'], ['slug' => 'details', 'title' => '明细数据'], ['slug' => 'compensation', 'title' => '赔偿'], ['slug' => 'edge-banding-payables', 'title' => '封边条应付表'], diff --git a/deploy/local-init.sh b/deploy/local-init.sh index ff012e3f..fe32e159 100644 --- a/deploy/local-init.sh +++ b/deploy/local-init.sh @@ -245,7 +245,10 @@ else fi echo "==> [2/5] 启动本地容器..." -docker compose -f "$compose_file" up -d --no-build +# 首次引导时 app 在安装前不会通过健康检查,snapshot_worker 又依赖该健康 +# 状态;先只启动数据库并确认 bootstrap 状态,再启动网页容器,避免旧的 +# APP_INSTALLED=true 让 entrypoint 与首次安装命令并发迁移空数据库。 +docker compose -f "$compose_file" up -d --no-build mysql echo "==> [3/5] 等待临时 MySQL 就绪..." mysql_ready=0 @@ -273,8 +276,52 @@ if [ "$db_host" = "mysql" ]; then set_env_value SYSTEM_DATABASE_MODE bootstrap set_env_value UPSTREAM_STORED_CONFIG_ENABLED false set_env_value UPSTREAM_LOCAL_CONFIG_ENABLED false + + db_prefix="$(read_env_value DB_PREFIX)" + if ! [[ "$db_prefix" =~ ^[A-Za-z0-9_]{0,50}$ ]]; then + echo "错误:本机引导库表前缀无效,已停止初始化。" >&2 + exit 1 + fi + + bootstrap_table_count="$( + docker compose -f "$compose_file" exec -T mysql \ + mysql -N -h 127.0.0.1 -u akaunting -pakaunting123 akaunting \ + -e "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'akaunting' AND table_name IN ('${db_prefix}users', '${db_prefix}settings', '${db_prefix}companies')" \ + 2>/dev/null \ + | tr -d '\r' \ + || true + )" + + bootstrap_any_table_count="$( + docker compose -f "$compose_file" exec -T mysql \ + mysql -N -h 127.0.0.1 -u akaunting -pakaunting123 akaunting \ + -e "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'akaunting'" \ + 2>/dev/null \ + | tr -d '\r' \ + || true + )" + + if [ "$bootstrap_table_count" != "3" ]; then + app_installed="false" + set_env_value APP_INSTALLED false + echo "==> 检测到本机引导库尚未安装或数据卷已重建,将自动执行 bootstrap 初始化。" + fi + + if [ "$app_installed" = "false" ] && [ "${bootstrap_any_table_count:-0}" -gt 0 ]; then + echo "==> 清理上次中断后留下的本机 bootstrap 半安装表..." + docker compose -f "$compose_file" exec -T mysql \ + mysql -h 127.0.0.1 -u root -prootpass \ + -e "DROP DATABASE IF EXISTS akaunting; CREATE DATABASE akaunting CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; GRANT ALL PRIVILEGES ON akaunting.* TO 'akaunting'@'%'; FLUSH PRIVILEGES;" + fi fi + +docker compose -f "$compose_file" up -d --no-build app + +# 容器可能已经存在而没有重新执行 entrypoint。显式复用同一权限初始化逻辑, +# 修复历史 root CLI 遗留目录;该命令本身不运行 Artisan。 +docker compose -f "$compose_file" exec -T -u root app jiangling-entrypoint true + if [ "$db_host" = "mysql" ] && [ "$app_installed" != "true" ] && [ "$app_installed" != "1" ]; then echo "==> [4/5] 初始化仅供首次登录使用的本机引导库..." @@ -332,6 +379,14 @@ if [ "$ready" -ne 1 ]; then exit 1 fi +docker compose -f "$compose_file" up -d --no-build snapshot_worker + +if ! docker compose -f "$compose_file" ps --status running --services \ + | grep -qx 'snapshot_worker'; then + echo "错误:上游快照 worker 未能启动。" >&2 + exit 1 +fi + http_code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 15 \ http://127.0.0.1:18088/auth/login || true)" diff --git a/modules/EcommerceFinance/Repositories/EdgeBandingRepository.php b/modules/EcommerceFinance/Repositories/EdgeBandingRepository.php index 2cf2ad25..24d0a38a 100644 --- a/modules/EcommerceFinance/Repositories/EdgeBandingRepository.php +++ b/modules/EcommerceFinance/Repositories/EdgeBandingRepository.php @@ -24,23 +24,26 @@ class EdgeBandingRepository /** @return array{data:array>,total:int} */ public function prices(array $filters, int $page, int $limit): array { - $where = ["p.number LIKE 'FBT%'"]; + $where = [ + "ISNULL(d.[delete], 0) = 0", + 'price_info.latest_price IS NOT NULL', + ]; $bindings = []; if (($filters['raw_name'] ?? '') !== '') { - $where[] = 'p.name LIKE ? ESCAPE \'\\\''; + $where[] = 'd.name LIKE ? ESCAPE \'\\\''; $bindings[] = $this->contains($filters['raw_name']); } if (($filters['raw_code'] ?? '') !== '') { - $where[] = 'p.number LIKE ? ESCAPE \'\\\''; + $where[] = 'd.number LIKE ? ESCAPE \'\\\''; $bindings[] = $this->contains($filters['raw_code']); } if (($filters['production_code'] ?? '') !== '') { - $where[] = "CONCAT(p.number, 'F01') LIKE ? ESCAPE '\\'"; + $where[] = 'd.proNumber LIKE ? ESCAPE \'\\\''; $bindings[] = $this->contains($filters['production_code']); } if (($filters['supplier'] ?? '') !== '') { - $where[] = 'p.supplier LIKE ? ESCAPE \'\\\''; + $where[] = 'supplier_info.target_supplier LIKE ? ESCAPE \'\\\''; $bindings[] = $this->contains($filters['supplier']); } @@ -51,14 +54,29 @@ class EdgeBandingRepository $sql = "WITH source_rows AS ( SELECT - p.name AS raw_name, - p.number AS raw_code, - CONCAT(p.number, 'F01') AS production_code, - CAST(p.price AS decimal(18,2)) AS latest_price, - p.supplier AS target_supplier, - ROW_NUMBER() OVER (ORDER BY p.number, p.supplier) AS row_no, + d.name AS raw_name, + d.number AS raw_code, + d.proNumber AS production_code, + CAST(price_info.latest_price AS decimal(18,2)) AS latest_price, + supplier_info.target_supplier, + ROW_NUMBER() OVER (ORDER BY d.number, d.proNumber) AS row_no, COUNT(*) OVER () AS total_count - FROM [dbo].[pm_averageUnitPrice] p + FROM [dbo].[pm_edgeBandData] d + OUTER APPLY ( + SELECT TOP 1 ad.giveSupplierName AS target_supplier + FROM [dbo].[pm_edgeBandApplyDetail] ad + WHERE ad.proNumber = d.proNumber + AND ISNULL(ad.giveSupplierName, '') <> '' + ORDER BY ad.inDate DESC, ad.Id DESC + ) supplier_info + OUTER APPLY ( + SELECT TOP 1 p.priceA AS latest_price + FROM [dbo].[pm_edgeBandProfitInfo] p + WHERE p.name = d.name + AND p.giveSupplierName = supplier_info.target_supplier + AND ISNULL(p.priceA, 0) > 0 + ORDER BY p.inDate DESC, p.id DESC + ) price_info WHERE {$whereSql} ) SELECT raw_name, raw_code, production_code, @@ -80,38 +98,40 @@ class EdgeBandingRepository /** @return array{data:array>,total:int} */ public function details(array $filters, int $page, int $limit): array { - $where = ["s.procode LIKE 'FBT%'"]; + $where = ["d.number LIKE 'FBT%'"]; $bindings = []; if (($filters['date'] ?? '') !== '') { - $where[] = 'CONVERT(date, s.sendTime) = ?'; + $where[] = 'CONVERT(date, d.inDate) = ?'; $bindings[] = $filters['date']; } if (($filters['production_code'] ?? '') !== '') { - $where[] = 's.procode LIKE ? ESCAPE \'\\\''; + $where[] = 'd.number LIKE ? ESCAPE \'\\\''; $bindings[] = $this->contains($filters['production_code']); } if (($filters['raw_name'] ?? '') !== '') { - $where[] = 's.skuname LIKE ? ESCAPE \'\\\''; + $where[] = 'd.name LIKE ? ESCAPE \'\\\''; $bindings[] = $this->contains($filters['raw_name']); } if (($filters['return_flag'] ?? '') === 'return') { - $where[] = 'ISNULL(s.settlementStock, 0) < 0'; + $where[] = 'COALESCE(d.inCount, d.deliveryCount, 0) < 0'; } elseif (($filters['return_flag'] ?? '') === 'normal') { - $where[] = 'ISNULL(s.settlementStock, 0) >= 0'; + $where[] = 'COALESCE(d.inCount, d.deliveryCount, 0) >= 0'; } if (($filters['outbound_supplier'] ?? '') !== '') { - $where[] = 's.supplier LIKE ? ESCAPE \'\\\''; + $where[] = 'd.giveSupplier LIKE ? ESCAPE \'\\\''; $bindings[] = $this->contains($filters['outbound_supplier']); } if (($filters['arrival_supplier'] ?? '') !== '') { - $where[] = 's.whName LIKE ? ESCAPE \'\\\''; + $where[] = 'd.arrSupplier LIKE ? ESCAPE \'\\\''; $bindings[] = $this->contains($filters['arrival_supplier']); } if (($filters['outbound_no'] ?? '') !== '') { - // 现有结算表没有单独的出库单号,快递/送货号作为可检索的外部单号。 - $where[] = 'ISNULL(s.expressNo, \'\') LIKE ? ESCAPE \'\\\''; - $bindings[] = $this->contains($filters['outbound_no']); + $where[] = "(ISNULL(d.deliveryNo, '') LIKE ? ESCAPE '\\' + OR ISNULL(d.sendNo, '') LIKE ? ESCAPE '\\')"; + $needle = $this->contains($filters['outbound_no']); + $bindings[] = $needle; + $bindings[] = $needle; } $whereSql = implode(' AND ', $where); @@ -121,36 +141,46 @@ class EdgeBandingRepository $sql = "WITH source_rows AS ( SELECT - CONVERT(varchar(19), s.sendTime, 120) AS inbound_date, - s.procode AS production_code, - s.skuname AS raw_name, - CAST(ISNULL(s.settlementStock, 0) AS decimal(18,2)) AS outbound_quantity, - s.supplier AS outbound_supplier, - ISNULL(s.whName, '') AS arrival_supplier, - CONVERT(varchar(10), s.sendTime, 120) AS batch_date, - '' AS outbound_no, - ISNULL(s.remark, '') AS outbound_remark, - '' AS requisition_no, - ISNULL(s.expressNo, '') AS delivery_no, - CAST(ISNULL(s.settlementStock, 0) AS decimal(18,2)) AS received_quantity, - CAST(ISNULL(s.settlementPrice, 0) AS decimal(18,2)) AS reconciliation_amount, - CAST(0 AS decimal(18,2)) AS freight, - CAST(ISNULL(a.price, 0) AS decimal(18,2)) AS edge_band_unit_price, - CAST(ISNULL(s.settlementStock, 0) * ISNULL(a.price, 0) AS decimal(18,2)) AS payable_amount, - CAST(ISNULL(s.singlePrice, 0) AS decimal(18,2)) AS furniture_unit_price, - CAST(ISNULL(s.settlementPrice, 0) AS decimal(18,2)) AS furniture_amount, - CAST(ISNULL(s.singlePrice, 0) - ISNULL(a.price, 0) AS decimal(18,2)) AS price_difference, - CAST(ISNULL(s.settlementPrice, 0) - (ISNULL(s.settlementStock, 0) * ISNULL(a.price, 0)) AS decimal(18,2)) AS profit, - ROW_NUMBER() OVER (ORDER BY s.sendTime DESC, s.Id DESC) AS row_no, + CONVERT(varchar(19), d.inDate, 120) AS inbound_date, + d.number AS production_code, + d.name AS raw_name, + CAST(ISNULL(d.deliveryCount, 0) AS decimal(18,2)) AS outbound_quantity, + ISNULL(d.giveSupplier, '') AS outbound_supplier, + ISNULL(d.arrSupplier, '') AS arrival_supplier, + CONVERT(varchar(10), d.date, 120) AS batch_date, + ISNULL(d.deliveryNo, '') AS outbound_no, + ISNULL(d.deliveryRemarks, '') AS outbound_remark, + ISNULL(d.applyNo, '') AS requisition_no, + ISNULL(d.numberType, '') AS number_type, + ISNULL(d.sendNo, '') AS delivery_no, + CAST(ISNULL(d.inCount, 0) AS decimal(18,2)) AS received_quantity, + CAST(COALESCE(d.priceBAll, ISNULL(d.inCount, 0) * ISNULL(d.priceB, 0), 0) AS decimal(18,2)) AS reconciliation_amount, + CAST(ISNULL(freight_info.transportCost, 0) AS decimal(18,2)) AS freight, + CAST(ISNULL(d.price, 0) AS decimal(18,2)) AS edge_band_unit_price, + CAST(COALESCE(d.priceAll, ISNULL(d.inCount, 0) * ISNULL(d.price, 0), 0) AS decimal(18,2)) AS payable_amount, + CAST(ISNULL(d.priceB, 0) AS decimal(18,2)) AS furniture_unit_price, + CAST(COALESCE(d.priceBAll, ISNULL(d.inCount, 0) * ISNULL(d.priceB, 0), 0) AS decimal(18,2)) AS furniture_amount, + CAST(ISNULL(d.priceB, 0) - ISNULL(d.price, 0) AS decimal(18,2)) AS price_difference, + CAST( + COALESCE(d.priceBAll, ISNULL(d.inCount, 0) * ISNULL(d.priceB, 0), 0) + - COALESCE(d.priceAll, ISNULL(d.inCount, 0) * ISNULL(d.price, 0), 0) + AS decimal(18,2) + ) AS profit, + ROW_NUMBER() OVER (ORDER BY d.inDate DESC, d.id DESC) AS row_no, COUNT(*) OVER () AS total_count - FROM [dbo].[fys_supplierGoods_sttlement_sale] s - LEFT JOIN [dbo].[pm_averageUnitPrice] a - ON a.number = s.procode AND a.supplier = s.supplier + FROM [dbo].[finance_edgeBandDeductio] d + OUTER APPLY ( + SELECT TOP 1 ad.transportCost + FROM [dbo].[pm_edgeBandApplyDetail] ad + WHERE ad.applyNo = d.applyNo + AND (ad.name = d.name OR ad.proNumber LIKE CONCAT(d.number, '%')) + ORDER BY CASE WHEN ad.name = d.name THEN 0 ELSE 1 END, ad.Id DESC + ) freight_info WHERE {$whereSql} ) SELECT inbound_date, production_code, raw_name, outbound_quantity, outbound_supplier, arrival_supplier, batch_date, - outbound_no, outbound_remark, requisition_no, delivery_no, + outbound_no, outbound_remark, requisition_no, number_type, delivery_no, received_quantity, reconciliation_amount, freight, edge_band_unit_price, payable_amount, furniture_unit_price, furniture_amount, price_difference, profit, total_count @@ -163,7 +193,6 @@ class EdgeBandingRepository foreach ($rows as &$row) { $row['unit'] = '米'; $row['return_flag'] = (float) ($row['outbound_quantity'] ?? 0) < 0 ? '退货' : '正常出库'; - $row['number_type'] = '申领'; } return $this->pageResult($rows); diff --git a/modules/EcommerceFinance/Repositories/InventoryRepository.php b/modules/EcommerceFinance/Repositories/InventoryRepository.php new file mode 100644 index 00000000..1d822c97 --- /dev/null +++ b/modules/EcommerceFinance/Repositories/InventoryRepository.php @@ -0,0 +1,143 @@ +>,total:int,totals:array} */ + public function boardInventoryDetails(array $filters, int $page, int $limit): array + { + $where = [ + 's.stockType = ?', + 's.[month] = (SELECT MAX(latest.[month]) FROM [dbo].[pm_boardInventoryDetail_snapshot] latest WHERE latest.stockType = ?)', + ]; + $bindings = ['素板', '素板']; + + if (($filters['manufacturer'] ?? '') !== '') { + $where[] = 's.supplier LIKE ? ESCAPE \'\\\''; + $bindings[] = $this->contains($filters['manufacturer']); + } + if (($filters['specification'] ?? '') !== '') { + $where[] = 's.name LIKE ? ESCAPE \'\\\''; + $bindings[] = $this->contains($filters['specification']); + } + if (($filters['matching_value'] ?? '') !== '') { + $where[] = 's.number LIKE ? ESCAPE \'\\\''; + $bindings[] = $this->contains($filters['matching_value']); + } + if (($filters['date_from'] ?? '') !== '') { + $where[] = 'CONVERT(date, COALESCE(s.[date], s.[month])) >= ?'; + $bindings[] = $filters['date_from']; + } + if (($filters['date_to'] ?? '') !== '') { + $where[] = 'CONVERT(date, COALESCE(s.[date], s.[month])) <= ?'; + $bindings[] = $filters['date_to']; + } + + $page = max($page, 1); + $limit = min(max($limit, 1), 100); + $offset = ($page - 1) * $limit; + $whereSql = implode(' AND ', $where); + + $sql = "WITH source_rows AS ( + SELECT + ISNULL(s.supplier, '') AS manufacturer, + CONVERT(varchar(10), COALESCE(s.[date], s.[month]), 120) AS [date], + ISNULL(s.name, '') AS specification, + ISNULL(s.number, '') AS matching_value, + CAST(ISNULL(s.lastBalance, 0) AS decimal(18,2)) AS opening_balance, + CAST(ISNULL(s.averagePrice, 0) AS decimal(18,2)) AS weighted_average_price, + CAST(ISNULL(s.inCount, 0) AS decimal(18,2)) AS stock_in, + CAST(ISNULL(s.deliveryCount, 0) AS decimal(18,2)) AS lamination_out, + CAST(ISNULL(s.balance, 0) AS decimal(18,2)) AS inventory, + CAST(ISNULL(s.surplusPrice, 0) AS decimal(18,2)) AS remaining_value, + CAST(ISNULL(s.inCount, 0) * ISNULL(s.averagePrice, 0) AS decimal(18,2)) AS stock_in_amount, + CAST(ISNULL(s.balance, 0) * ISNULL(s.averagePrice, 0) AS decimal(18,2)) AS closing_remaining_value, + CAST(ISNULL(s.lastBalance, 0) * ISNULL(s.averagePrice, 0) AS decimal(18,2)) AS previous_remaining_value, + CAST(ISNULL(s.deliveryCount, 0) * ISNULL(s.averagePrice, 0) AS decimal(18,2)) AS stock_out_amount, + CAST(SUM(CAST(ISNULL(s.lastBalance, 0) AS decimal(38,2))) OVER () AS decimal(18,2)) AS summary_opening_balance, + CAST(SUM(CAST(ISNULL(s.inCount, 0) AS decimal(38,2))) OVER () AS decimal(18,2)) AS summary_stock_in, + CAST(SUM(CAST(ISNULL(s.deliveryCount, 0) AS decimal(38,2))) OVER () AS decimal(18,2)) AS summary_lamination_out, + CAST(SUM(CAST(ISNULL(s.balance, 0) AS decimal(38,2))) OVER () AS decimal(18,2)) AS summary_inventory, + CAST(SUM(CAST(ISNULL(s.surplusPrice, 0) AS decimal(38,2))) OVER () AS decimal(18,2)) AS summary_remaining_value, + CAST(SUM(CAST(ISNULL(s.inCount, 0) * ISNULL(s.averagePrice, 0) AS decimal(38,2))) OVER () AS decimal(18,2)) AS summary_stock_in_amount, + CAST(SUM(CAST(ISNULL(s.balance, 0) * ISNULL(s.averagePrice, 0) AS decimal(38,2))) OVER () AS decimal(18,2)) AS summary_closing_remaining_value, + CAST(SUM(CAST(ISNULL(s.lastBalance, 0) * ISNULL(s.averagePrice, 0) AS decimal(38,2))) OVER () AS decimal(18,2)) AS summary_previous_remaining_value, + CAST(SUM(CAST(ISNULL(s.deliveryCount, 0) * ISNULL(s.averagePrice, 0) AS decimal(38,2))) OVER () AS decimal(18,2)) AS summary_stock_out_amount, + ROW_NUMBER() OVER ( + ORDER BY COALESCE(s.[date], s.[month]) DESC, s.supplier, s.name, s.number, s.id DESC + ) AS row_no, + COUNT(*) OVER () AS total_count + FROM [dbo].[pm_boardInventoryDetail_snapshot] s + WHERE {$whereSql} + ) + SELECT manufacturer, [date], specification, matching_value, + opening_balance, weighted_average_price, stock_in, lamination_out, + inventory, remaining_value, stock_in_amount, closing_remaining_value, + previous_remaining_value, stock_out_amount, + summary_opening_balance, summary_stock_in, summary_lamination_out, + summary_inventory, summary_remaining_value, summary_stock_in_amount, + summary_closing_remaining_value, summary_previous_remaining_value, + summary_stock_out_amount, total_count + FROM source_rows + WHERE row_no BETWEEN ? AND ? + ORDER BY row_no"; + + $rows = $this->query()->select($sql, array_merge($bindings, [$offset + 1, $offset + $limit])); + $totals = array_fill_keys(self::SUMMARY_KEYS, 0.0); + + if ($rows !== []) { + foreach (self::SUMMARY_KEYS as $key) { + $totals[$key] = (float) ($rows[0]['summary_' . $key] ?? 0); + } + } + + $total = (int) ($rows[0]['total_count'] ?? 0); + + foreach ($rows as &$row) { + unset($row['total_count']); + + foreach (self::SUMMARY_KEYS as $key) { + unset($row['summary_' . $key]); + } + } + + return ['data' => $rows, 'total' => $total, 'totals' => $totals]; + } + + private function query(): UpstreamReadOnlyQuery + { + return $this->query ??= $this->queries->forSource(self::SOURCE); + } + + private function contains(string $value): string + { + return '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], trim($value)) . '%'; + } +} diff --git a/modules/EcommerceFinance/Repositories/RawBoardPayablesRepository.php b/modules/EcommerceFinance/Repositories/RawBoardPayablesRepository.php new file mode 100644 index 00000000..1123dd90 --- /dev/null +++ b/modules/EcommerceFinance/Repositories/RawBoardPayablesRepository.php @@ -0,0 +1,263 @@ +>,total:int} */ + public function prices(array $filters, int $page, int $limit): array + { + $where = ['ISNULL(p.price, 0) > 0']; + $bindings = []; + + if (($filters['raw_name'] ?? '') !== '') { + $where[] = "ISNULL(plain_material.name, '') LIKE ? ESCAPE '\\'"; + $bindings[] = $this->contains($filters['raw_name']); + } + if (($filters['raw_code'] ?? '') !== '') { + $where[] = "p.number LIKE ? ESCAPE '\\'"; + $bindings[] = $this->contains($filters['raw_code']); + } + if (($filters['production_code'] ?? '') !== '') { + $where[] = "COALESCE(NULLIF(p.proNumber, ''), plain_material.proNumber, '') LIKE ? ESCAPE '\\'"; + $bindings[] = $this->contains($filters['production_code']); + } + if (($filters['supplier'] ?? '') !== '') { + $where[] = "COALESCE(NULLIF(supplier_info.supplierText, ''), p.supplier, '') LIKE ? ESCAPE '\\'"; + $bindings[] = $this->contains($filters['supplier']); + } + + $page = max($page, 1); + $limit = min(max($limit, 1), 100); + $offset = ($page - 1) * $limit; + $whereSql = implode(' AND ', $where); + + $sql = "WITH latest_plain_materials AS ( + SELECT number, MAX(Id) AS id + FROM [dbo].[pm_plainPlateData] + WHERE ISNULL([delete], 0) = 0 + AND NULLIF(number, '') IS NOT NULL + GROUP BY number + ), deduplicated_prices AS ( + SELECT + number, + supplier, + proNumber, + price, + MAX(COALESCE(updateTime, createTime)) AS sort_time, + MAX(id) AS sort_id + FROM [dbo].[pm_boardPurchasePrice] + WHERE ISNULL(price, 0) > 0 + GROUP BY number, supplier, proNumber, price + ), source_rows AS ( + SELECT + ISNULL(plain_material.name, '') AS raw_name, + ISNULL(p.number, '') AS raw_code, + COALESCE(NULLIF(p.proNumber, ''), plain_material.proNumber, '') AS production_code, + CAST(p.price AS decimal(18,2)) AS latest_price, + COALESCE(NULLIF(supplier_info.supplierText, ''), p.supplier, '') AS target_supplier, + ROW_NUMBER() OVER ( + ORDER BY p.sort_time DESC, p.sort_id DESC + ) AS row_no, + COUNT(*) OVER () AS total_count + FROM deduplicated_prices p + INNER JOIN latest_plain_materials latest_plain + ON latest_plain.number = p.number + INNER JOIN [dbo].[pm_plainPlateData] plain_material + ON plain_material.Id = latest_plain.id + OUTER APPLY ( + SELECT TOP 1 i.supplierText + FROM [dbo].[pm_boardPriceChangeImport] i + WHERE i.number = p.number + AND i.supplier = p.supplier + AND NULLIF(LTRIM(RTRIM(i.supplierText)), '') IS NOT NULL + ORDER BY i.id DESC + ) supplier_info + WHERE {$whereSql} + ) + SELECT raw_name, raw_code, production_code, latest_price, + target_supplier, total_count + FROM source_rows + WHERE row_no BETWEEN ? AND ? + ORDER BY row_no"; + + $rows = $this->query()->select($sql, array_merge($bindings, [$offset + 1, $offset + $limit])); + + foreach ($rows as &$row) { + $row['price_type'] = '采购价'; + $row['factory_type'] = '素板厂'; + } + + return $this->pageResult($rows); + } + + /** @return array{data:array>,total:int,totals:array} */ + public function reconciliations(array $filters, int $page, int $limit): array + { + $where = ['1 = 1']; + $bindings = []; + + if (($filters['date_from'] ?? '') !== '') { + $where[] = 'CONVERT(date, d.inDate) >= ?'; + $bindings[] = $filters['date_from']; + } + if (($filters['date_to'] ?? '') !== '') { + $where[] = 'CONVERT(date, d.inDate) <= ?'; + $bindings[] = $filters['date_to']; + } + if (($filters['production_code'] ?? '') !== '') { + $where[] = "d.number LIKE ? ESCAPE '\\'"; + $bindings[] = $this->contains($filters['production_code']); + } + if (($filters['raw_name'] ?? '') !== '') { + $where[] = "d.name LIKE ? ESCAPE '\\'"; + $bindings[] = $this->contains($filters['raw_name']); + } + if (($filters['outbound_supplier'] ?? '') !== '') { + $where[] = "d.giveSupplier LIKE ? ESCAPE '\\'"; + $bindings[] = $this->contains($filters['outbound_supplier']); + } + if (($filters['arrival_supplier'] ?? '') !== '') { + $where[] = "d.arrSupplier LIKE ? ESCAPE '\\'"; + $bindings[] = $this->contains($filters['arrival_supplier']); + } + if (($filters['outbound_no'] ?? '') !== '') { + $where[] = "(ISNULL(d.deliveryNo, '') LIKE ? ESCAPE '\\' + OR ISNULL(d.sendNo, '') LIKE ? ESCAPE '\\')"; + $needle = $this->contains($filters['outbound_no']); + $bindings[] = $needle; + $bindings[] = $needle; + } + if (($filters['return_flag'] ?? '') === 'return') { + $where[] = 'ISNULL(d.deliveryCount, 0) < 0'; + } elseif (($filters['return_flag'] ?? '') === 'normal') { + $where[] = 'ISNULL(d.deliveryCount, 0) >= 0'; + } + + $page = max($page, 1); + $limit = min(max($limit, 1), 100); + $offset = ($page - 1) * $limit; + $whereSql = implode(' AND ', $where); + + $sql = "WITH source_rows AS ( + SELECT + CONVERT(varchar(19), d.inDate, 120) AS inbound_date, + CONVERT(varchar(19), d.deliveryDate, 120) AS outbound_date, + ISNULL(d.number, '') AS production_code, + ISNULL(d.name, '') AS raw_name, + CAST(ISNULL(d.deliveryCount, 0) AS decimal(18,2)) AS outbound_quantity, + ISNULL(d.giveSupplier, '') AS outbound_supplier, + ISNULL(d.arrSupplier, '') AS arrival_supplier, + CONVERT(varchar(10), d.date, 120) AS batch_date, + ISNULL(d.deliveryNo, '') AS outbound_no, + ISNULL(d.deliveryRemarks, '') AS outbound_remark, + ISNULL(d.applyNo, '') AS requisition_no, + ISNULL(d.numberType, '') AS number_type, + ISNULL(d.sendNo, '') AS delivery_no, + CAST(ISNULL(d.inCount, 0) AS decimal(18,2)) AS received_quantity, + CAST(ISNULL(d.priceA, 0) AS decimal(18,2)) AS unit_price, + CAST( + COALESCE(d.moneyA, ISNULL(d.inCount, 0) * ISNULL(d.priceA, 0), 0) + AS decimal(18,2) + ) AS reconciliation_amount, + CAST(NULL AS decimal(18,2)) AS freight_with_allocation, + CAST(NULL AS decimal(18,2)) AS allocated_freight, + CAST(ISNULL(freight_info.transportCost, 0) AS decimal(18,2)) AS freight, + CAST(SUM(CAST(ISNULL(d.deliveryCount, 0) AS decimal(38,2))) OVER () AS decimal(18,2)) AS summary_outbound_quantity, + CAST(SUM(CAST(ISNULL(d.inCount, 0) AS decimal(38,2))) OVER () AS decimal(18,2)) AS summary_received_quantity, + CAST(SUM(CAST( + COALESCE(d.moneyA, ISNULL(d.inCount, 0) * ISNULL(d.priceA, 0), 0) + AS decimal(38,2) + )) OVER () AS decimal(18,2)) AS summary_reconciliation_amount, + ROW_NUMBER() OVER (ORDER BY d.inDate DESC, d.id DESC) AS row_no, + COUNT(*) OVER () AS total_count + FROM [dbo].[finance_boardDeduction] d + OUTER APPLY ( + SELECT TOP 1 ad.transportCost + FROM [dbo].[pm_boardApplyDetail] ad + WHERE ad.applyNo = d.applyNo + AND ad.name = d.name + ORDER BY + CASE + WHEN ad.inCount = d.inCount AND ad.deliveryCount = d.deliveryCount THEN 0 + ELSE 1 + END, + ad.Id DESC + ) freight_info + WHERE {$whereSql} + ) + SELECT inbound_date, outbound_date, production_code, raw_name, + outbound_quantity, outbound_supplier, arrival_supplier, + batch_date, outbound_no, outbound_remark, requisition_no, + number_type, delivery_no, received_quantity, unit_price, + reconciliation_amount, freight_with_allocation, + allocated_freight, freight, summary_outbound_quantity, + summary_received_quantity, summary_reconciliation_amount, + total_count + FROM source_rows + WHERE row_no BETWEEN ? AND ? + ORDER BY row_no"; + + $rows = $this->query()->select($sql, array_merge($bindings, [$offset + 1, $offset + $limit])); + $totals = array_fill_keys(self::RECONCILIATION_SUMMARY_KEYS, 0.0); + + if ($rows !== []) { + foreach (self::RECONCILIATION_SUMMARY_KEYS as $key) { + $totals[$key] = (float) ($rows[0]['summary_' . $key] ?? 0); + } + } + + foreach ($rows as &$row) { + $row['unit'] = '张'; + $row['return_flag'] = (float) ($row['outbound_quantity'] ?? 0) < 0 ? '退货' : '正常出库'; + + foreach (self::RECONCILIATION_SUMMARY_KEYS as $key) { + unset($row['summary_' . $key]); + } + } + + return array_merge($this->pageResult($rows), ['totals' => $totals]); + } + + private function query(): UpstreamReadOnlyQuery + { + return $this->query ??= $this->queries->forSource(self::SOURCE); + } + + private function contains(string $value): string + { + return '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], trim($value)) . '%'; + } + + /** @param array> $rows */ + private function pageResult(array $rows): array + { + $total = (int) ($rows[0]['total_count'] ?? 0); + + foreach ($rows as &$row) { + unset($row['total_count']); + } + + return ['data' => $rows, 'total' => $total]; + } +} diff --git a/modules/EcommerceFinance/Services/JianSunSnapshotRefresher.php b/modules/EcommerceFinance/Services/JianSunSnapshotRefresher.php index 3245d29a..676056fe 100644 --- a/modules/EcommerceFinance/Services/JianSunSnapshotRefresher.php +++ b/modules/EcommerceFinance/Services/JianSunSnapshotRefresher.php @@ -6,7 +6,9 @@ use InvalidArgumentException; use Modules\EcommerceFinance\Contracts\UpstreamSnapshotRefresher; use Modules\EcommerceFinance\Repositories\EdgeBandingRepository; use Modules\EcommerceFinance\Repositories\GoodsReconciliationRepository; +use Modules\EcommerceFinance\Repositories\InventoryRepository; use Modules\EcommerceFinance\Repositories\PinduoduoSpreadBillRepository; +use Modules\EcommerceFinance\Repositories\RawBoardPayablesRepository; use Modules\EcommerceFinance\Repositories\SupplierPayableRepository; use Modules\EcommerceFinance\Repositories\TmallTaobaoSpreadBillRepository; @@ -19,7 +21,9 @@ class JianSunSnapshotRefresher implements UpstreamSnapshotRefresher private readonly JianSunQueryCache $cache, private readonly EdgeBandingRepository $edgeBanding, private readonly GoodsReconciliationRepository $goods, + private readonly InventoryRepository $inventory, private readonly PinduoduoSpreadBillRepository $pinduoduo, + private readonly RawBoardPayablesRepository $rawBoardPayables, private readonly TmallTaobaoSpreadBillRepository $taobaoAlipay, private readonly SupplierPayableRepository $supplier, ) { @@ -62,6 +66,11 @@ class JianSunSnapshotRefresher implements UpstreamSnapshotRefresher 'goods.summary' => $this->goods->summary($filters), 'goods.options' => $this->goods->filterOptions(), + 'inventory.board-details' => $this->inventory->boardInventoryDetails($filters, $page, $limit), + + 'raw-board.prices' => $this->rawBoardPayables->prices($filters, $page, $limit), + 'raw-board.reconciliations' => $this->rawBoardPayables->reconciliations($filters, $page, $limit), + 'pinduoduo-bills.list' => $this->pinduoduo->paginate( $storeName, $fromDate, diff --git a/resources/views/business/pages/inventory-management/board-inventory-details.blade.php b/resources/views/business/pages/inventory-management/board-inventory-details.blade.php index 2091de43..290e1712 100644 --- a/resources/views/business/pages/inventory-management/board-inventory-details.blade.php +++ b/resources/views/business/pages/inventory-management/board-inventory-details.blade.php @@ -1,28 +1,160 @@ - @include('business.pages.partials.data-table', [ - 'title' => $page['title'] ?? '板材库存明细', - 'showIndex' => false, - 'filters' => [ - ['name' => 'manufacturer', 'label' => '厂家', 'placeholder' => '输入厂家'], - ['name' => 'date_from', 'label' => '日期从', 'type' => 'date'], - ['name' => 'date_to', 'label' => '日期至', 'type' => 'date'], - ['name' => 'specification', 'label' => '规格', 'placeholder' => '输入规格'], - ['name' => 'matching_value', 'label' => '匹配用', 'placeholder' => '输入匹配条件'], - ], - 'columns' => [ - ['key' => 'manufacturer', 'label' => '厂家'], - ['key' => 'date', 'label' => '日期'], - ['key' => 'specification', 'label' => '规格'], - ['key' => 'matching_value', 'label' => '匹配用'], - ['key' => 'opening_balance', 'label' => '上月结余'], - ['key' => 'weighted_average_price', 'label' => '加权平均单价'], - ['key' => 'stock_in', 'label' => '入库'], - ['key' => 'lamination_out', 'label' => '压贴'], - ['key' => 'inventory', 'label' => '库存'], - ['key' => 'remaining_value', 'label' => '剩余价值'], - ['key' => 'stock_in_amount', 'label' => '入库金额'], - ['key' => 'closing_remaining_value', 'label' => '剩余价值'], - ['key' => 'previous_remaining_value', 'label' => '上月剩余价值'], - ['key' => 'stock_out_amount', 'label' => '出库金额'], - ], - 'emptyText' => '暂无板材库存明细数据', - ]) +@php + $inventory = $inventoryData ?? []; + $result = $inventory['result'] ?? ['data' => [], 'total' => 0, 'totals' => []]; + $rows = $result['data'] ?? []; + $totals = $result['totals'] ?? []; + $filters = $inventory['filters'] ?? []; + $total = (int) ($result['total'] ?? 0); + $currentPage = (int) ($inventory['page'] ?? 1); + $lastPage = (int) ($inventory['last_page'] ?? 1); + $error = $inventory['error'] ?? null; + $dataStatus = $inventory['status']['X-Caiwu-Data-Status'] ?? null; +@endphp + +
+ @include('business.pages.partials.upstream-data-styles') + + @if ($error) + + @endif + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + 重置 + +
+
+ +
+
+
上月结余
+
{{ number_format((float) ($totals['opening_balance'] ?? 0), 0) }}
+
+
+
入库
+
{{ number_format((float) ($totals['stock_in'] ?? 0), 0) }}
+
+
+
压贴
+
{{ number_format((float) ($totals['lamination_out'] ?? 0), 0) }}
+
+
+
库存
+
{{ number_format((float) ($totals['inventory'] ?? 0), 0) }}
+
+
+
剩余价值
+
{{ number_format((float) ($totals['closing_remaining_value'] ?? 0), 2) }}
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + @forelse ($rows as $row) + + + + + + + + + + + + + + + + + @empty + + + + @endforelse + +
厂家日期规格匹配用上月结余加权平均单价入库压贴库存剩余价值入库金额剩余价值上月剩余价值出库金额
{{ $row['manufacturer'] ?: '-' }}{{ $row['date'] ?: '-' }}{{ $row['specification'] ?: '-' }}{{ $row['matching_value'] ?: '-' }}{{ number_format((float) $row['opening_balance'], 0) }}{{ number_format((float) $row['weighted_average_price'], 2) }}{{ number_format((float) $row['stock_in'], 0) }}{{ number_format((float) $row['lamination_out'], 0) }}{{ number_format((float) $row['inventory'], 0) }}{{ number_format((float) $row['remaining_value'], 2) }}{{ number_format((float) $row['stock_in_amount'], 2) }}{{ number_format((float) $row['closing_remaining_value'], 2) }}{{ number_format((float) $row['previous_remaining_value'], 2) }}{{ number_format((float) $row['stock_out_amount'], 2) }}
+ {{ $error ? '数据源暂不可用,请稍后重试。' : '当前筛选条件下没有素板库存快照,请调整筛选条件后重试。' }} +
+
+ +
+ + 共 {{ number_format($total) }} 条记录 + @if ($dataStatus) + · 数据状态 {{ $dataStatus }} + @endif + +
+ @if ($currentPage <= 1) + + + + @else + + + + @endif + 第 {{ $currentPage }} / {{ max($lastPage, 1) }} 页 + @if ($currentPage >= $lastPage) + + + + @else + + + + @endif +
+
+
+
diff --git a/resources/views/business/pages/partials/upstream-data-styles.blade.php b/resources/views/business/pages/partials/upstream-data-styles.blade.php new file mode 100644 index 00000000..4339c8ea --- /dev/null +++ b/resources/views/business/pages/partials/upstream-data-styles.blade.php @@ -0,0 +1,310 @@ + diff --git a/resources/views/business/pages/raw-board-payables/board-prices.blade.php b/resources/views/business/pages/raw-board-payables/board-prices.blade.php index 05901d04..43c883d5 100644 --- a/resources/views/business/pages/raw-board-payables/board-prices.blade.php +++ b/resources/views/business/pages/raw-board-payables/board-prices.blade.php @@ -1,21 +1,107 @@ - @include('business.pages.courier-payables.partials.data-table', [ - 'title' => $page['title'] ?? '素板价格', - 'showIndex' => false, - 'filters' => [ - ['name' => 'price_type', 'label' => '价格类型', 'type' => 'select', 'options' => ['' => '全部']], - ['name' => 'material_name', 'label' => '原料名称', 'placeholder' => '输入原料名称'], - ['name' => 'material_no', 'label' => '原料编号', 'placeholder' => '输入原料编号'], - ['name' => 'target_vendor', 'label' => '目标厂商', 'placeholder' => '输入目标厂商'], - ['name' => 'vendor_type', 'label' => '厂商类型', 'type' => 'select', 'options' => ['' => '全部']], - ], - 'columns' => [ - ['key' => 'price_type', 'label' => '价格类型'], - ['key' => 'material_name', 'label' => '原料名称'], - ['key' => 'material_no', 'label' => '原料编号'], - ['key' => 'production_no', 'label' => '生产编号'], - ['key' => 'latest_price', 'label' => '最新价格'], - ['key' => 'target_vendor', 'label' => '目标厂商'], - ['key' => 'vendor_type', 'label' => '厂商类型'], - ], - 'emptyText' => '暂无素板价格数据', - ]) +@php + $rawBoard = $rawBoardData ?? []; + $result = $rawBoard['result'] ?? ['data' => [], 'total' => 0]; + $rows = $result['data'] ?? []; + $filters = $rawBoard['filters'] ?? []; + $total = (int) ($result['total'] ?? 0); + $currentPage = (int) ($rawBoard['page'] ?? 1); + $lastPage = (int) ($rawBoard['last_page'] ?? 1); + $error = $rawBoard['error'] ?? null; + $dataStatus = $rawBoard['status']['X-Caiwu-Data-Status'] ?? null; +@endphp + +
+ @include('business.pages.partials.upstream-data-styles') + + @if ($error) + + @endif + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + 重置 + +
+
+ +
+
+ + + + + + + + + + + + + + @forelse ($rows as $row) + + + + + + + + + + @empty + + + + @endforelse + +
价格类型原料名称原料编号生产编号最新价格目标厂商厂商类型
{{ $row['price_type'] ?: '-' }}{{ $row['raw_name'] ?: '-' }}{{ $row['raw_code'] ?: '-' }}{{ $row['production_code'] ?: '-' }}{{ number_format((float) $row['latest_price'], 2) }}{{ $row['target_supplier'] ?: '-' }}{{ $row['factory_type'] ?: '-' }}
{{ $error ? '数据源暂不可用,请稍后重试。' : '当前筛选条件下没有匹配的素板价格。' }}
+
+
+ + 共 {{ number_format($total) }} 条记录 + @if ($dataStatus) + · 数据状态 {{ $dataStatus }} + @endif + +
+ @if ($currentPage <= 1) + + @else + + @endif + 第 {{ $currentPage }} / {{ max($lastPage, 1) }} 页 + @if ($currentPage >= $lastPage) + + @else + + @endif +
+
+
+
diff --git a/resources/views/business/pages/raw-board-payables/board-reconciliation.blade.php b/resources/views/business/pages/raw-board-payables/board-reconciliation.blade.php index 8598cfe0..7986bb2f 100644 --- a/resources/views/business/pages/raw-board-payables/board-reconciliation.blade.php +++ b/resources/views/business/pages/raw-board-payables/board-reconciliation.blade.php @@ -1,36 +1,172 @@ - @include('business.pages.courier-payables.partials.data-table', [ - 'title' => $page['title'] ?? '素板对账', - 'showIndex' => false, - 'filters' => [ - ['name' => 'production_no', 'label' => '原料生产编号', 'placeholder' => '输入生产编号'], - ['name' => 'material_name', 'label' => '原料名称', 'placeholder' => '输入原料名称'], - ['name' => 'outbound_vendor', 'label' => '出库厂商', 'placeholder' => '输入出库厂商'], - ['name' => 'arrival_vendor', 'label' => '到货厂商', 'placeholder' => '输入到货厂商'], - ['name' => 'stock_in_from', 'label' => '入库日期从', 'type' => 'date'], - ['name' => 'stock_in_to', 'label' => '入库日期至', 'type' => 'date'], - ], - 'columns' => [ - ['key' => 'stocked_in_at', 'label' => '入库日期'], - ['key' => 'stocked_out_at', 'label' => '出库日期'], - ['key' => 'production_no', 'label' => '原料生产编号'], - ['key' => 'material_name', 'label' => '原料名称'], - ['key' => 'outbound_quantity', 'label' => '出库数量'], - ['key' => 'unit', 'label' => '单位'], - ['key' => 'return_flag', 'label' => '退货标识'], - ['key' => 'outbound_vendor', 'label' => '出库厂商'], - ['key' => 'arrival_vendor', 'label' => '到货厂商'], - ['key' => 'batch_date', 'label' => '批次日期'], - ['key' => 'outbound_no', 'label' => '出库单号'], - ['key' => 'outbound_remark', 'label' => '出库备注'], - ['key' => 'requisition_no', 'label' => '申领单号'], - ['key' => 'number_type', 'label' => '单号类型'], - ['key' => 'delivery_no', 'label' => '送货单号'], - ['key' => 'received_quantity', 'label' => '实收数量'], - ['key' => 'unit_price', 'label' => '单价'], - ['key' => 'reconciliation_amount', 'label' => '对账金额'], - ['key' => 'freight_with_allocation', 'label' => '运费(包含分摊运费)'], - ['key' => 'allocated_freight', 'label' => '分摊运费'], - ['key' => 'freight', 'label' => '运费'], - ], - 'emptyText' => '暂无素板对账数据', - ]) +@php + $rawBoard = $rawBoardData ?? []; + $result = $rawBoard['result'] ?? ['data' => [], 'total' => 0, 'totals' => []]; + $rows = $result['data'] ?? []; + $totals = $result['totals'] ?? []; + $filters = $rawBoard['filters'] ?? []; + $total = (int) ($result['total'] ?? 0); + $currentPage = (int) ($rawBoard['page'] ?? 1); + $lastPage = (int) ($rawBoard['last_page'] ?? 1); + $error = $rawBoard['error'] ?? null; + $dataStatus = $rawBoard['status']['X-Caiwu-Data-Status'] ?? null; + $number = static fn ($value) => $value === null || $value === '' ? '-' : number_format((float) $value, 2); +@endphp + +
+ @include('business.pages.partials.upstream-data-styles') + + @if ($error) + + @endif + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + 重置 + +
+
+ +
+
+
出库数量
+
{{ number_format((float) ($totals['outbound_quantity'] ?? 0), 2) }}
+
+
+
实收数量
+
{{ number_format((float) ($totals['received_quantity'] ?? 0), 2) }}
+
+
+
对账金额
+
{{ number_format((float) ($totals['reconciliation_amount'] ?? 0), 2) }}
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + @forelse ($rows as $row) + + + + + + + + + + + + + + + + + + + + + + + + @empty + + + + @endforelse + +
入库日期出库日期原料生产编号原料名称出库数量单位退货标识出库厂商到货厂商批次日期出库单号出库备注申领单号单号类型送货单号实收数量单价对账金额运费(包含分摊运费)分摊运费运费
{{ $row['inbound_date'] ?: '-' }}{{ $row['outbound_date'] ?: '-' }}{{ $row['production_code'] ?: '-' }}{{ $row['raw_name'] ?: '-' }}{{ $number($row['outbound_quantity'] ?? null) }}{{ $row['unit'] ?: '-' }}{{ $row['return_flag'] ?: '-' }}{{ $row['outbound_supplier'] ?: '-' }}{{ $row['arrival_supplier'] ?: '-' }}{{ $row['batch_date'] ?: '-' }}{{ $row['outbound_no'] ?: '-' }}{{ $row['outbound_remark'] ?: '-' }}{{ $row['requisition_no'] ?: '-' }}{{ $row['number_type'] ?: '-' }}{{ $row['delivery_no'] ?: '-' }}{{ $number($row['received_quantity'] ?? null) }}{{ $number($row['unit_price'] ?? null) }}{{ $number($row['reconciliation_amount'] ?? null) }}{{ $number($row['freight_with_allocation'] ?? null) }}{{ $number($row['allocated_freight'] ?? null) }}{{ $number($row['freight'] ?? null) }}
{{ $error ? '数据源暂不可用,请稍后重试。' : '当前筛选条件下没有匹配的素板对账数据。' }}
+
+
+ + 共 {{ number_format($total) }} 条记录 + @if ($dataStatus) + · 数据状态 {{ $dataStatus }} + @endif + +
+ @if ($currentPage <= 1) + + @else + + @endif + 第 {{ $currentPage }} / {{ max($lastPage, 1) }} 页 + @if ($currentPage >= $lastPage) + + @else + + @endif +
+
+
+
diff --git a/tests/Feature/Business/PagesTest.php b/tests/Feature/Business/PagesTest.php index 0f92d581..6a143dc2 100644 --- a/tests/Feature/Business/PagesTest.php +++ b/tests/Feature/Business/PagesTest.php @@ -145,11 +145,10 @@ class PagesTest extends FeatureTestCase ['courier-payables', 'compensation-details', '暂无赔偿明细'], ['courier-payables', 'advance-details', '暂无借支明细'], ['courier-payables', 'other-adjustments', '暂无其他扣款补款明细'], - ['raw-board-payables', 'board-prices', '暂无素板价格数据'], - ['raw-board-payables', 'board-reconciliation', '暂无素板对账数据'], + ['raw-board-payables', 'board-prices', '价格类型'], + ['raw-board-payables', 'board-reconciliation', '运费(包含分摊运费)'], ['raw-board-payables', 'compensation-details', '暂无素板赔付明细'], ['raw-board-payables', 'board-payables-report', '暂无素板应付汇总数据'], - ['inventory-management', 'board-inventory-details', '暂无板材库存明细数据'], ]; foreach ($pages as [$section, $page, $expectedText]) { @@ -157,7 +156,27 @@ class PagesTest extends FeatureTestCase ->get(route('business.pages.show', compact('section', 'page'))) ->assertOk() ->assertSeeText($expectedText) - ->assertDontSee('class="business-blank-page"', false); + ->assertDontSee('class="business-blank-page"', false); } } + + public function testBoardInventoryDetailsRendersWorkbookColumnsAsItsOwnPage() + { + $this->user->attachPermission( + Permission::where('name', 'read-admin-panel')->firstOrFail() + ); + + $this->loginAs() + ->get(route('business.pages.show', [ + 'section' => 'inventory-management', + 'page' => 'board-inventory-details', + ])) + ->assertOk() + ->assertSee('class="upstream-data-table upstream-data-table--inventory"', false) + ->assertSeeText('加权平均单价') + ->assertSeeText('上月剩余价值') + ->assertSeeText('出库金额') + ->assertDontSee('class="business-data-page"', false) + ->assertDontSee('class="business-blank-page"', false); + } } diff --git a/tests/Feature/Installation/ApplicationReadyTest.php b/tests/Feature/Installation/ApplicationReadyTest.php index cbc18137..74f5cc4d 100644 --- a/tests/Feature/Installation/ApplicationReadyTest.php +++ b/tests/Feature/Installation/ApplicationReadyTest.php @@ -15,6 +15,7 @@ class ApplicationReadyTest extends TestCase public function test_ready_command_rejects_uninstalled_application(): void { config(['app.installed' => false]); - $this->artisan('app:ready')->assertFailed(); + $this->artisan('app:ready') + ->assertExitCode(\App\Console\Commands\ApplicationReady::EXIT_NOT_INSTALLED); } } diff --git a/tests/Feature/Installation/VerifySystemDatabaseTest.php b/tests/Feature/Installation/VerifySystemDatabaseTest.php new file mode 100644 index 00000000..271ab04c --- /dev/null +++ b/tests/Feature/Installation/VerifySystemDatabaseTest.php @@ -0,0 +1,65 @@ +shouldReceive('current')->once()->andReturn([ + 'mode' => 'bootstrap', + ]); + $this->app->instance(SystemDatabaseConfiguration::class, $configuration); + + $this->artisan('system-database:verify') + ->assertExitCode(VerifySystemDatabase::EXIT_CONFIG_NOT_LOADED); + } + + public function test_verify_command_reports_database_connection_failure(): void + { + $this->enableSharedMode(); + + $configuration = Mockery::mock(SystemDatabaseConfiguration::class); + $configuration->shouldReceive('current')->once()->andReturn([ + 'mode' => 'shared', + 'connection' => 'missing-connection', + ]); + $this->app->instance(SystemDatabaseConfiguration::class, $configuration); + + $this->artisan('system-database:verify') + ->assertExitCode(VerifySystemDatabase::EXIT_DATABASE_CONNECTION_FAILED); + } + + public function test_verify_command_reports_system_table_failure(): void + { + $this->enableSharedMode(); + + $connection = (string) config('database.default'); + $settings = [ + 'mode' => 'shared', + 'connection' => $connection, + ]; + $configuration = Mockery::mock(SystemDatabaseConfiguration::class); + $configuration->shouldReceive('current')->once()->andReturn($settings); + $configuration->shouldReceive('probe')->once()->with($settings)->andThrow(new RuntimeException('hidden')); + $this->app->instance(SystemDatabaseConfiguration::class, $configuration); + + $this->artisan('system-database:verify') + ->assertExitCode(VerifySystemDatabase::EXIT_SYSTEM_TABLE_VALIDATION_FAILED); + } + + private function enableSharedMode(): void + { + config([ + 'upstreams.stored_configuration.enabled' => true, + 'upstreams.local_configuration.enabled' => false, + ]); + } +} diff --git a/tests/Unit/EcommerceFinance/EdgeBandingRepositoryTest.php b/tests/Unit/EcommerceFinance/EdgeBandingRepositoryTest.php index 5d5e020d..368e19c2 100644 --- a/tests/Unit/EcommerceFinance/EdgeBandingRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/EdgeBandingRepositoryTest.php @@ -13,9 +13,12 @@ class EdgeBandingRepositoryTest extends TestCase { $query = $this->createMock(UpstreamReadOnlyQuery::class); $query->expects($this->once())->method('select')->willReturnCallback( - function (string $sql): array { + function (string $sql, array $bindings): array { UpstreamReadOnlyQuery::assertReadOnlySql($sql); - $this->assertStringContainsString('[dbo].[pm_averageUnitPrice]', $sql); + $this->assertStringContainsString('[dbo].[pm_edgeBandData]', $sql); + $this->assertStringContainsString('[dbo].[pm_edgeBandApplyDetail]', $sql); + $this->assertStringContainsString('[dbo].[pm_edgeBandProfitInfo]', $sql); + $this->assertSame(['%FBT442%', 1, 25], $bindings); return [[ 'raw_code' => 'FBT442', @@ -28,7 +31,7 @@ class EdgeBandingRepositoryTest extends TestCase $factory = $this->createMock(UpstreamReadOnlyQueryFactory::class); $factory->expects($this->once())->method('forSource')->with('jiansun')->willReturn($query); - $result = (new EdgeBandingRepository($factory))->prices([], 1, 25); + $result = (new EdgeBandingRepository($factory))->prices(['raw_code' => 'FBT442'], 1, 25); $this->assertSame(1, $result['total']); $this->assertSame('FBT442', $result['data'][0]['raw_code']); @@ -41,13 +44,20 @@ class EdgeBandingRepositoryTest extends TestCase { $query = $this->createMock(UpstreamReadOnlyQuery::class); $query->expects($this->once())->method('select')->willReturnCallback( - function (string $sql): array { + function (string $sql, array $bindings): array { UpstreamReadOnlyQuery::assertReadOnlySql($sql); - $this->assertStringContainsString('[dbo].[fys_supplierGoods_sttlement_sale]', $sql); + $this->assertStringContainsString('[dbo].[finance_edgeBandDeductio]', $sql); + $this->assertStringContainsString('[dbo].[pm_edgeBandApplyDetail]', $sql); + $this->assertStringContainsString('AS reconciliation_amount', $sql); + $this->assertStringContainsString('AS price_difference', $sql); + $this->assertStringContainsString('AS profit', $sql); + $this->assertStringContainsString('requisition_no, number_type, delivery_no', $sql); + $this->assertSame(['%JSN10972026070203CK%', '%JSN10972026070203CK%', 1, 25], $bindings); return [[ 'production_code' => 'FBT440', 'outbound_quantity' => '14000.00', + 'number_type' => '补领', 'total_count' => 1, ]]; } @@ -56,13 +66,15 @@ class EdgeBandingRepositoryTest extends TestCase $factory = $this->createMock(UpstreamReadOnlyQueryFactory::class); $factory->expects($this->once())->method('forSource')->with('jiansun')->willReturn($query); - $result = (new EdgeBandingRepository($factory))->details([], 1, 25); + $result = (new EdgeBandingRepository($factory))->details([ + 'outbound_no' => 'JSN10972026070203CK', + ], 1, 25); $this->assertSame(1, $result['total']); $this->assertSame('FBT440', $result['data'][0]['production_code']); $this->assertSame('米', $result['data'][0]['unit']); $this->assertSame('正常出库', $result['data'][0]['return_flag']); - $this->assertSame('申领', $result['data'][0]['number_type']); + $this->assertSame('补领', $result['data'][0]['number_type']); } public function test_payable_rows_expose_full_result_totals(): void diff --git a/tests/Unit/EcommerceFinance/InventoryRepositoryTest.php b/tests/Unit/EcommerceFinance/InventoryRepositoryTest.php new file mode 100644 index 00000000..024e1f90 --- /dev/null +++ b/tests/Unit/EcommerceFinance/InventoryRepositoryTest.php @@ -0,0 +1,92 @@ +createMock(UpstreamReadOnlyQuery::class); + $query->expects($this->once())->method('select')->willReturnCallback( + function (string $sql, array $bindings): array { + UpstreamReadOnlyQuery::assertReadOnlySql($sql); + + $this->assertStringContainsString('[dbo].[pm_boardInventoryDetail_snapshot]', $sql); + $this->assertStringContainsString('s.stockType = ?', $sql); + $this->assertStringContainsString('MAX(latest.[month])', $sql); + $this->assertStringContainsString('AS stock_in_amount', $sql); + $this->assertStringContainsString('AS closing_remaining_value', $sql); + $this->assertStringContainsString('AS previous_remaining_value', $sql); + $this->assertStringContainsString('AS stock_out_amount', $sql); + $this->assertSame( + ['素板', '素板', '%苏阳%', '%15密度板%', '%15MDF%', '2026-06-01', '2026-06-30', 26, 50], + $bindings, + ); + + return [[ + 'manufacturer' => '苏阳', + 'specification' => '15密度板', + 'stock_in_amount' => '590.04', + 'closing_remaining_value' => '283428.10', + 'summary_opening_balance' => '12060.00', + 'summary_stock_in' => '18.00', + 'summary_lamination_out' => '3415.00', + 'summary_inventory' => '8645.00', + 'summary_remaining_value' => '507270.50', + 'summary_stock_in_amount' => '590.04', + 'summary_closing_remaining_value' => '283428.10', + 'summary_previous_remaining_value' => '395326.80', + 'summary_stock_out_amount' => '111933.70', + 'total_count' => 51, + ]]; + }, + ); + + $factory = $this->createMock(UpstreamReadOnlyQueryFactory::class); + $factory->expects($this->once())->method('forSource')->with('jiansun')->willReturn($query); + + $result = (new InventoryRepository($factory))->boardInventoryDetails([ + 'manufacturer' => '苏阳', + 'specification' => '15密度板', + 'matching_value' => '15MDF', + 'date_from' => '2026-06-01', + 'date_to' => '2026-06-30', + ], 2, 25); + + $this->assertSame(51, $result['total']); + $this->assertSame('苏阳', $result['data'][0]['manufacturer']); + $this->assertSame(8645.0, $result['totals']['inventory']); + $this->assertSame(283428.1, $result['totals']['closing_remaining_value']); + $this->assertArrayNotHasKey('total_count', $result['data'][0]); + $this->assertArrayNotHasKey('summary_inventory', $result['data'][0]); + } + + public function test_board_inventory_escapes_like_filters_and_caps_page_size(): void + { + $query = $this->createMock(UpstreamReadOnlyQuery::class); + $query->expects($this->once())->method('select')->willReturnCallback( + function (string $sql, array $bindings): array { + UpstreamReadOnlyQuery::assertReadOnlySql($sql); + $this->assertSame(['素板', '素板', '%A\\%B\\_C%', 1, 100], $bindings); + + return []; + }, + ); + + $factory = $this->createMock(UpstreamReadOnlyQueryFactory::class); + $factory->method('forSource')->willReturn($query); + + $result = (new InventoryRepository($factory))->boardInventoryDetails([ + 'manufacturer' => 'A%B_C', + ], 0, 1000); + + $this->assertSame(0, $result['total']); + $this->assertSame([], $result['data']); + $this->assertSame(0.0, $result['totals']['inventory']); + } +} diff --git a/tests/Unit/EcommerceFinance/JianSunSnapshotRefresherTest.php b/tests/Unit/EcommerceFinance/JianSunSnapshotRefresherTest.php index 39ecb1a7..4963455b 100644 --- a/tests/Unit/EcommerceFinance/JianSunSnapshotRefresherTest.php +++ b/tests/Unit/EcommerceFinance/JianSunSnapshotRefresherTest.php @@ -5,7 +5,9 @@ namespace Tests\Unit\EcommerceFinance; use Closure; use Modules\EcommerceFinance\Repositories\EdgeBandingRepository; use Modules\EcommerceFinance\Repositories\GoodsReconciliationRepository; +use Modules\EcommerceFinance\Repositories\InventoryRepository; use Modules\EcommerceFinance\Repositories\PinduoduoSpreadBillRepository; +use Modules\EcommerceFinance\Repositories\RawBoardPayablesRepository; use Modules\EcommerceFinance\Repositories\SupplierPayableRepository; use Modules\EcommerceFinance\Repositories\TmallTaobaoSpreadBillRepository; use Modules\EcommerceFinance\Services\JianSunQueryCache; @@ -17,21 +19,6 @@ class JianSunSnapshotRefresherTest extends TestCase { public function test_pinduoduo_flow_filter_is_preserved_during_background_refresh(): void { - $cache = new class extends JianSunQueryCache { - public mixed $value = null; - - public function __construct() - { - } - - public function refresh(string $scope, array $parameters, Closure $resolver): string - { - $this->value = $resolver(); - - return 'fresh'; - } - }; - $pinduoduo = new class extends PinduoduoSpreadBillRepository { public array $arguments = []; @@ -53,19 +40,7 @@ class JianSunSnapshotRefresherTest extends TestCase } }; - $edgeBanding = (new ReflectionClass(EdgeBandingRepository::class))->newInstanceWithoutConstructor(); - $goods = (new ReflectionClass(GoodsReconciliationRepository::class))->newInstanceWithoutConstructor(); - $taobaoAlipay = (new ReflectionClass(TmallTaobaoSpreadBillRepository::class))->newInstanceWithoutConstructor(); - $supplier = (new ReflectionClass(SupplierPayableRepository::class))->newInstanceWithoutConstructor(); - $refresher = new JianSunSnapshotRefresher( - $cache, - $edgeBanding, - $goods, - $pinduoduo, - $taobaoAlipay, - $supplier - ); - + $refresher = $this->refresher(pinduoduo: $pinduoduo); $status = $refresher->refresh([ 'scope' => 'pinduoduo-bills.list', 'parameters' => [ @@ -89,21 +64,6 @@ class JianSunSnapshotRefresherTest extends TestCase public function test_taobao_alipay_filters_are_preserved_during_background_refresh(): void { - $cache = new class extends JianSunQueryCache { - public mixed $value = null; - - public function __construct() - { - } - - public function refresh(string $scope, array $parameters, Closure $resolver): string - { - $this->value = $resolver(); - - return 'fresh'; - } - }; - $taobaoAlipay = new class extends TmallTaobaoSpreadBillRepository { public array $arguments = []; @@ -125,19 +85,7 @@ class JianSunSnapshotRefresherTest extends TestCase } }; - $edgeBanding = (new ReflectionClass(EdgeBandingRepository::class))->newInstanceWithoutConstructor(); - $goods = (new ReflectionClass(GoodsReconciliationRepository::class))->newInstanceWithoutConstructor(); - $pinduoduo = (new ReflectionClass(PinduoduoSpreadBillRepository::class))->newInstanceWithoutConstructor(); - $supplier = (new ReflectionClass(SupplierPayableRepository::class))->newInstanceWithoutConstructor(); - $refresher = new JianSunSnapshotRefresher( - $cache, - $edgeBanding, - $goods, - $pinduoduo, - $taobaoAlipay, - $supplier - ); - + $refresher = $this->refresher(taobaoAlipay: $taobaoAlipay); $status = $refresher->refresh([ 'scope' => 'taobao-alipay-bills.list', 'parameters' => [ @@ -158,4 +106,102 @@ class JianSunSnapshotRefresherTest extends TestCase $taobaoAlipay->arguments ); } + + public function test_inventory_scope_is_available_to_background_refresh(): void + { + $inventory = new class extends InventoryRepository { + public array $arguments = []; + + public function __construct() + { + } + + public function boardInventoryDetails(array $filters, int $page, int $limit): array + { + $this->arguments = func_get_args(); + + return ['data' => [], 'total' => 0, 'totals' => []]; + } + }; + + $refresher = $this->refresher(inventory: $inventory); + $status = $refresher->refresh([ + 'scope' => 'inventory.board-details', + 'parameters' => [ + 'filters' => ['manufacturer' => '测试厂'], + 'page' => 2, + 'limit' => 50, + ], + ]); + + $this->assertSame('fresh', $status); + $this->assertSame([['manufacturer' => '测试厂'], 2, 50], $inventory->arguments); + } + + public function test_raw_board_reconciliation_scope_is_available_to_background_refresh(): void + { + $rawBoardPayables = new class extends RawBoardPayablesRepository { + public array $arguments = []; + + public function __construct() + { + } + + public function reconciliations(array $filters, int $page, int $limit): array + { + $this->arguments = func_get_args(); + + return ['data' => [], 'total' => 0, 'totals' => []]; + } + }; + + $refresher = $this->refresher(rawBoardPayables: $rawBoardPayables); + $status = $refresher->refresh([ + 'scope' => 'raw-board.reconciliations', + 'parameters' => [ + 'filters' => ['supplier' => '测试厂'], + 'page' => 2, + 'limit' => 50, + ], + ]); + + $this->assertSame('fresh', $status); + $this->assertSame([['supplier' => '测试厂'], 2, 50], $rawBoardPayables->arguments); + } + + private function refresher( + ?InventoryRepository $inventory = null, + ?PinduoduoSpreadBillRepository $pinduoduo = null, + ?RawBoardPayablesRepository $rawBoardPayables = null, + ?TmallTaobaoSpreadBillRepository $taobaoAlipay = null, + ): JianSunSnapshotRefresher { + return new JianSunSnapshotRefresher( + new class extends JianSunQueryCache { + public mixed $value = null; + + public function __construct() + { + } + + public function refresh(string $scope, array $parameters, Closure $resolver): string + { + $this->value = $resolver(); + + return 'fresh'; + } + }, + $this->placeholder(EdgeBandingRepository::class), + $this->placeholder(GoodsReconciliationRepository::class), + $inventory ?? $this->placeholder(InventoryRepository::class), + $pinduoduo ?? $this->placeholder(PinduoduoSpreadBillRepository::class), + $rawBoardPayables ?? $this->placeholder(RawBoardPayablesRepository::class), + $taobaoAlipay ?? $this->placeholder(TmallTaobaoSpreadBillRepository::class), + $this->placeholder(SupplierPayableRepository::class), + ); + } + + private function placeholder(string $class): object + { + return (new ReflectionClass($class))->newInstanceWithoutConstructor(); + } } diff --git a/tests/Unit/EcommerceFinance/RawBoardPayablesRepositoryTest.php b/tests/Unit/EcommerceFinance/RawBoardPayablesRepositoryTest.php new file mode 100644 index 00000000..65bee428 --- /dev/null +++ b/tests/Unit/EcommerceFinance/RawBoardPayablesRepositoryTest.php @@ -0,0 +1,121 @@ +createMock(UpstreamReadOnlyQuery::class); + $query->expects($this->once())->method('select')->willReturnCallback( + function (string $sql, array $bindings): array { + UpstreamReadOnlyQuery::assertReadOnlySql($sql); + + $this->assertStringContainsString('[dbo].[pm_boardPurchasePrice]', $sql); + $this->assertStringContainsString('[dbo].[pm_plainPlateData]', $sql); + $this->assertStringContainsString('[dbo].[pm_boardPriceChangeImport]', $sql); + $this->assertStringContainsString('deduplicated_prices AS', $sql); + $this->assertStringContainsString('GROUP BY number, supplier, proNumber, price', $sql); + $this->assertStringContainsString('AS latest_price', $sql); + $this->assertSame( + ['%密度板%', '%12MDF%', '%12MDFS32%', '%冠之杰%', 26, 50], + $bindings, + ); + + return [[ + 'raw_name' => '12密度板', + 'raw_code' => '12MDF', + 'production_code' => '12MDFS32', + 'latest_price' => '23.50', + 'target_supplier' => '冠之杰', + 'total_count' => 51, + ]]; + }, + ); + + $factory = $this->createMock(UpstreamReadOnlyQueryFactory::class); + $factory->expects($this->once())->method('forSource')->with('jiansun')->willReturn($query); + + $result = (new RawBoardPayablesRepository($factory))->prices([ + 'raw_name' => '密度板', + 'raw_code' => '12MDF', + 'production_code' => '12MDFS32', + 'supplier' => '冠之杰', + ], 2, 25); + + $this->assertSame(51, $result['total']); + $this->assertSame('采购价', $result['data'][0]['price_type']); + $this->assertSame('素板厂', $result['data'][0]['factory_type']); + $this->assertArrayNotHasKey('total_count', $result['data'][0]); + } + + public function test_reconciliations_calculate_amount_totals_and_preserve_workbook_columns(): void + { + $query = $this->createMock(UpstreamReadOnlyQuery::class); + $query->expects($this->once())->method('select')->willReturnCallback( + function (string $sql, array $bindings): array { + UpstreamReadOnlyQuery::assertReadOnlySql($sql); + + $this->assertStringContainsString('[dbo].[finance_boardDeduction]', $sql); + $this->assertStringContainsString('[dbo].[pm_boardApplyDetail]', $sql); + $this->assertStringContainsString('AS reconciliation_amount', $sql); + $this->assertStringContainsString('AS freight_with_allocation', $sql); + $this->assertStringContainsString('AS allocated_freight', $sql); + $this->assertStringContainsString('AS summary_reconciliation_amount', $sql); + $this->assertStringContainsString('ISNULL(d.deliveryCount, 0) < 0', $sql); + $this->assertSame([ + '2026-06-01', + '2026-06-30', + '%12MDF%', + '%密度板%', + '%冠之杰%', + '%苏阳%', + '%JSAD\%01\_%', + '%JSAD\%01\_%', + 1, + 100, + ], $bindings); + + return [[ + 'production_code' => '12MDFS32', + 'raw_name' => '12密度板', + 'outbound_quantity' => '-10.00', + 'received_quantity' => '-10.00', + 'unit_price' => '23.50', + 'reconciliation_amount' => '-235.00', + 'freight' => '0.00', + 'summary_outbound_quantity' => '-10.00', + 'summary_received_quantity' => '-10.00', + 'summary_reconciliation_amount' => '-235.00', + 'total_count' => 1, + ]]; + }, + ); + + $factory = $this->createMock(UpstreamReadOnlyQueryFactory::class); + $factory->expects($this->once())->method('forSource')->with('jiansun')->willReturn($query); + + $result = (new RawBoardPayablesRepository($factory))->reconciliations([ + 'date_from' => '2026-06-01', + 'date_to' => '2026-06-30', + 'production_code' => '12MDF', + 'raw_name' => '密度板', + 'outbound_supplier' => '冠之杰', + 'arrival_supplier' => '苏阳', + 'outbound_no' => 'JSAD%01_', + 'return_flag' => 'return', + ], 0, 1000); + + $this->assertSame(1, $result['total']); + $this->assertSame('张', $result['data'][0]['unit']); + $this->assertSame('退货', $result['data'][0]['return_flag']); + $this->assertSame(-235.0, $result['totals']['reconciliation_amount']); + $this->assertArrayNotHasKey('summary_reconciliation_amount', $result['data'][0]); + $this->assertArrayNotHasKey('total_count', $result['data'][0]); + } +} diff --git a/tests/Unit/Utilities/SystemDatabasePersistenceTest.php b/tests/Unit/Utilities/SystemDatabasePersistenceTest.php index 82ea9f3f..f68754c5 100644 --- a/tests/Unit/Utilities/SystemDatabasePersistenceTest.php +++ b/tests/Unit/Utilities/SystemDatabasePersistenceTest.php @@ -2,6 +2,7 @@ namespace Tests\Unit\Utilities; +use App\Exceptions\SystemDatabaseVerificationException; use App\Utilities\SystemDatabaseConfiguration; use RuntimeException; use Tests\TestCase; @@ -22,7 +23,7 @@ class SystemDatabasePersistenceTest extends TestCase $this->commands[] = $command; if ($command === 'system-database:verify') { - throw new RuntimeException('candidate failed'); + throw new SystemDatabaseVerificationException('系统表验证失败'); } } }; @@ -42,7 +43,10 @@ class SystemDatabasePersistenceTest extends TestCase $this->fail('验证失败时应抛出异常。'); } catch (RuntimeException $exception) { - $this->assertSame('新配置未通过应用级验证,已自动恢复原系统数据库。', $exception->getMessage()); + $this->assertSame( + '新配置未通过应用级验证(系统表验证失败),已自动恢复原系统数据库。', + $exception->getMessage() + ); $this->assertSame($original, file_get_contents($environmentPath)); $this->assertSame(['system-database:verify', 'app:ready'], $configuration->commands); } finally { @@ -118,6 +122,72 @@ class SystemDatabasePersistenceTest extends TestCase $this->assertSame('true', $environment['UPSTREAM_STORED_CONFIG_ENABLED']); $this->assertSame('false', $environment['UPSTREAM_LOCAL_CONFIG_ENABLED']); $this->assertFalse($environment['DB_PREFIX']); + $this->assertFalse($environment['DATABASE_URL']); + $this->assertFalse($environment['DB_HOST_READ']); + $this->assertFalse($environment['DB_HOST_WRITE']); + $this->assertFalse($environment['DB_SOCKET']); + } finally { + @unlink($environmentPath); + } + } + + public function test_php_cli_finder_returns_an_executable_for_fresh_verification(): void + { + $configuration = new class extends SystemDatabaseConfiguration { + public function executable(): string + { + return $this->phpExecutable(); + } + }; + + $executable = $configuration->executable(); + + $this->assertNotSame('', $executable); + $this->assertTrue(is_executable($executable)); + $this->assertSame('php', pathinfo($executable, PATHINFO_FILENAME)); + } + + public function test_failed_rollback_reports_candidate_and_rollback_stages(): void + { + $environmentPath = storage_path('framework/testing-system-database-rollback.env'); + $original = "DB_HOST=mysql\nDB_DATABASE=akaunting\nSYSTEM_DATABASE_MODE=bootstrap\n"; + file_put_contents($environmentPath, $original); + + $configuration = new class($environmentPath) extends SystemDatabaseConfiguration { + protected function runFreshVerification(string $command): void + { + throw new SystemDatabaseVerificationException( + $command === 'system-database:verify' + ? 'Laravel 数据库连接失败' + : '运行目录不可写' + ); + } + + protected function clearConfigurationCache(): void + { + } + }; + + try { + $configuration->persist([ + 'host' => 'shared-db.test', + 'port' => 3306, + 'database' => 'shared_system', + 'username' => 'shared_writer', + 'password' => 'shared-secret', + 'prefix' => 'ak_', + 'tls_required' => true, + 'tls_verify_server_cert' => false, + 'tls_ca' => '', + ]); + + $this->fail('新旧配置验证均失败时应抛出异常。'); + } catch (RuntimeException $exception) { + $this->assertSame( + '新配置未通过应用级验证(Laravel 数据库连接失败),旧配置文件已经恢复,但旧配置复核失败(运行目录不可写)。', + $exception->getMessage() + ); + $this->assertSame($original, file_get_contents($environmentPath)); } finally { @unlink($environmentPath); }