合并:本地共享数据库验证与原材料数据页面

This commit is contained in:
WuYongJie 2026-07-30 16:11:38 +08:00
parent 8a8ea93c63
commit 85f3db56ce
23 changed files with 2175 additions and 242 deletions

View File

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

View File

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

View File

@ -0,0 +1,9 @@
<?php
namespace App\Exceptions;
use RuntimeException;
class SystemDatabaseVerificationException extends RuntimeException
{
}

View File

@ -9,7 +9,9 @@ use Illuminate\Routing\Controller;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Modules\EcommerceFinance\Repositories\EdgeBandingRepository;
use Modules\EcommerceFinance\Repositories\InventoryRepository;
use Modules\EcommerceFinance\Repositories\PinduoduoSpreadBillRepository;
use Modules\EcommerceFinance\Repositories\RawBoardPayablesRepository;
use Modules\EcommerceFinance\Repositories\TmallTaobaoSpreadBillRepository;
use Modules\EcommerceFinance\Services\UpstreamQueryCacheFactory;
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
@ -19,8 +21,14 @@ class Pages extends Controller
{
private const EDGE_BANDING_SOURCE = 'jiansun';
private const INVENTORY_SOURCE = 'jiansun';
private const RAW_BOARD_SOURCE = 'jiansun';
public function __construct(
private readonly EdgeBandingRepository $edgeBanding,
private readonly InventoryRepository $inventory,
private readonly RawBoardPayablesRepository $rawBoardPayables,
private readonly UpstreamQueryCacheFactory $upstreamCaches,
) {
}
@ -103,6 +111,8 @@ class Pages extends Controller
'activeTab' => 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字段)
*/

View File

@ -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<string, string|false>
*/
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) {

View File

@ -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' => '封边条应付表'],

View File

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

View File

@ -24,23 +24,26 @@ class EdgeBandingRepository
/** @return array{data:array<int,array<string,mixed>>,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<int,array<string,mixed>>,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);

View File

@ -0,0 +1,143 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
/**
* 原材料库存管理只读数据仓库。
*/
class InventoryRepository
{
private const SOURCE = 'jiansun';
private const SUMMARY_KEYS = [
'opening_balance',
'stock_in',
'lamination_out',
'inventory',
'remaining_value',
'stock_in_amount',
'closing_remaining_value',
'previous_remaining_value',
'stock_out_amount',
];
private ?UpstreamReadOnlyQuery $query = null;
public function __construct(private readonly UpstreamReadOnlyQueryFactory $queries)
{
}
/** @return array{data:array<int,array<string,mixed>>,total:int,totals:array<string,float>} */
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)) . '%';
}
}

View File

@ -0,0 +1,263 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
/**
* 原材料素板应付只读数据仓库。
*/
class RawBoardPayablesRepository
{
private const SOURCE = 'jiansun';
private const RECONCILIATION_SUMMARY_KEYS = [
'outbound_quantity',
'received_quantity',
'reconciliation_amount',
];
private ?UpstreamReadOnlyQuery $query = null;
public function __construct(private readonly UpstreamReadOnlyQueryFactory $queries)
{
}
/** @return array{data:array<int,array<string,mixed>>,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<int,array<string,mixed>>,total:int,totals:array<string,float>} */
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<int,array<string,mixed>> $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];
}
}

View File

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

View File

@ -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
<div class="upstream-data-page upstream-data-page--spaced">
@include('business.pages.partials.upstream-data-styles')
@if ($error)
<div class="upstream-data-alert" role="alert">
<span class="material-icons-outlined" aria-hidden="true">error_outline</span>
<span>{{ $error }}</span>
</div>
@endif
<form class="upstream-data-toolbar" method="get" action="{{ request()->url() }}" aria-label="板材库存筛选">
<div class="upstream-data-field">
<label for="inventory-manufacturer">厂家</label>
<input id="inventory-manufacturer" name="manufacturer" type="text" value="{{ $filters['manufacturer'] ?? '' }}" placeholder="请输入厂家">
</div>
<div class="upstream-data-field">
<label for="inventory-date-from">日期从</label>
<input id="inventory-date-from" name="date_from" type="date" value="{{ $filters['date_from'] ?? '' }}">
</div>
<div class="upstream-data-field">
<label for="inventory-date-to">日期至</label>
<input id="inventory-date-to" name="date_to" type="date" value="{{ $filters['date_to'] ?? '' }}">
</div>
<div class="upstream-data-field">
<label for="inventory-specification">规格</label>
<input id="inventory-specification" name="specification" type="text" value="{{ $filters['specification'] ?? '' }}" placeholder="请输入规格">
</div>
<div class="upstream-data-field">
<label for="inventory-matching-value">匹配用</label>
<input id="inventory-matching-value" name="matching_value" type="text" value="{{ $filters['matching_value'] ?? '' }}" placeholder="请输入原料编号">
</div>
<div class="upstream-data-actions">
<button class="upstream-data-button upstream-data-button--primary" type="submit">
<span class="material-icons-outlined" aria-hidden="true">search</span>
<span>查询</span>
</button>
<a class="upstream-data-button" href="{{ request()->url() }}" title="清除筛选条件">
<span class="material-icons-outlined" aria-hidden="true">restart_alt</span>
<span>重置</span>
</a>
</div>
</form>
<dl class="upstream-data-summary upstream-data-summary--inventory" aria-label="库存汇总">
<div>
<dt>上月结余</dt>
<dd>{{ number_format((float) ($totals['opening_balance'] ?? 0), 0) }}</dd>
</div>
<div>
<dt>入库</dt>
<dd>{{ number_format((float) ($totals['stock_in'] ?? 0), 0) }}</dd>
</div>
<div>
<dt>压贴</dt>
<dd>{{ number_format((float) ($totals['lamination_out'] ?? 0), 0) }}</dd>
</div>
<div>
<dt>库存</dt>
<dd>{{ number_format((float) ($totals['inventory'] ?? 0), 0) }}</dd>
</div>
<div>
<dt>剩余价值</dt>
<dd>{{ number_format((float) ($totals['closing_remaining_value'] ?? 0), 2) }}</dd>
</div>
</dl>
<div class="upstream-data-table-panel">
<div class="upstream-data-table-scroll">
<table class="upstream-data-table upstream-data-table--inventory">
<thead>
<tr>
<th>厂家</th>
<th>日期</th>
<th>规格</th>
<th>匹配用</th>
<th class="is-number">上月结余</th>
<th class="is-number">加权平均单价</th>
<th class="is-number">入库</th>
<th class="is-number">压贴</th>
<th class="is-number">库存</th>
<th class="is-number" title="数据库快照中的剩余价值">剩余价值</th>
<th class="is-number">入库金额</th>
<th class="is-number" title="按库存乘以加权平均单价计算">剩余价值</th>
<th class="is-number">上月剩余价值</th>
<th class="is-number">出库金额</th>
</tr>
</thead>
<tbody>
@forelse ($rows as $row)
<tr>
<td>{{ $row['manufacturer'] ?: '-' }}</td>
<td>{{ $row['date'] ?: '-' }}</td>
<td>{{ $row['specification'] ?: '-' }}</td>
<td>{{ $row['matching_value'] ?: '-' }}</td>
<td class="is-number">{{ number_format((float) $row['opening_balance'], 0) }}</td>
<td class="is-number">{{ number_format((float) $row['weighted_average_price'], 2) }}</td>
<td class="is-number">{{ number_format((float) $row['stock_in'], 0) }}</td>
<td class="is-number">{{ number_format((float) $row['lamination_out'], 0) }}</td>
<td class="is-number">{{ number_format((float) $row['inventory'], 0) }}</td>
<td class="is-number">{{ number_format((float) $row['remaining_value'], 2) }}</td>
<td class="is-number">{{ number_format((float) $row['stock_in_amount'], 2) }}</td>
<td class="is-number">{{ number_format((float) $row['closing_remaining_value'], 2) }}</td>
<td class="is-number">{{ number_format((float) $row['previous_remaining_value'], 2) }}</td>
<td class="is-number">{{ number_format((float) $row['stock_out_amount'], 2) }}</td>
</tr>
@empty
<tr class="is-empty">
<td colspan="14">
{{ $error ? '数据源暂不可用,请稍后重试。' : '当前筛选条件下没有素板库存快照,请调整筛选条件后重试。' }}
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="upstream-data-pagination">
<span>
共 {{ number_format($total) }} 条记录
@if ($dataStatus)
· 数据状态 {{ $dataStatus }}
@endif
</span>
<div class="upstream-data-pagination__controls" aria-label="板材库存分页">
@if ($currentPage <= 1)
<span class="upstream-data-button is-disabled" aria-disabled="true" title="上一页">
<span class="material-icons-outlined" aria-hidden="true">chevron_left</span>
</span>
@else
<a class="upstream-data-button" href="{{ request()->fullUrlWithQuery(['data_page' => $currentPage - 1]) }}" aria-label="上一页" title="上一页">
<span class="material-icons-outlined" aria-hidden="true">chevron_left</span>
</a>
@endif
<span>第 {{ $currentPage }} / {{ max($lastPage, 1) }} 页</span>
@if ($currentPage >= $lastPage)
<span class="upstream-data-button is-disabled" aria-disabled="true" title="下一页">
<span class="material-icons-outlined" aria-hidden="true">chevron_right</span>
</span>
@else
<a class="upstream-data-button" href="{{ request()->fullUrlWithQuery(['data_page' => $currentPage + 1]) }}" aria-label="下一页" title="下一页">
<span class="material-icons-outlined" aria-hidden="true">chevron_right</span>
</a>
@endif
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,310 @@
<style>
.upstream-data-page {
color: var(--vip-content-ink, #171717);
font-family: "JetBrains Mono", "Microsoft YaHei", "PingFang SC", monospace;
font-size: 0.8125rem;
}
.upstream-data-toolbar {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 0.75rem;
margin-bottom: 1rem;
padding: 1rem;
border: 1px solid var(--vip-border, #d8d2c7);
border-radius: 0.75rem;
background: var(--vip-surface, #ffffff);
}
.upstream-data-page--spaced .upstream-data-toolbar,
.upstream-data-page--spaced .upstream-data-alert {
margin-top: 1.5rem;
}
.upstream-data-page--spaced .upstream-data-alert + .upstream-data-toolbar {
margin-top: 0;
}
.upstream-data-field {
display: flex;
min-width: 9rem;
flex: 1 1 10rem;
flex-direction: column;
gap: 0.35rem;
}
.upstream-data-field label {
color: var(--vip-content-muted, #595959);
font-size: 0.75rem;
font-weight: 500;
line-height: 1.333;
}
.upstream-data-field input,
.upstream-data-field select {
width: 100%;
height: 2.125rem;
min-width: 0;
padding: 0 0.625rem;
border: 1px solid var(--vip-border, #d8d2c7);
border-radius: 0.5rem;
background: var(--vip-surface, #ffffff);
color: var(--vip-content-ink, #171717);
font: inherit;
outline: none;
}
.upstream-data-field input:focus,
.upstream-data-field select:focus {
border-color: var(--vip-content-accent-dark, #856632);
box-shadow: 0 0 0 3px rgba(133, 102, 50, 0.2);
}
.upstream-data-actions,
.upstream-data-pagination__controls {
display: flex;
align-items: center;
gap: 0.5rem;
}
.upstream-data-button {
display: inline-flex;
height: 2.125rem;
align-items: center;
justify-content: center;
gap: 0.375rem;
padding: 0 0.875rem;
border: 1px solid var(--vip-border, #d8d2c7);
border-radius: 0.5rem;
background: var(--vip-surface, #ffffff);
color: var(--vip-content-ink, #171717);
font: inherit;
font-weight: 500;
line-height: 1;
text-decoration: none;
cursor: pointer;
}
.upstream-data-button--primary {
border-color: var(--vip-content-accent-dark, #856632);
background: var(--vip-content-accent-dark, #856632);
color: #ffffff;
}
.upstream-data-button:hover {
border-color: var(--vip-content-accent-dark, #856632);
background: var(--vip-content-accent-soft, #f7f0de);
color: var(--vip-content-ink, #171717);
}
.upstream-data-button--primary:hover {
border-color: #6a5129;
background: #6a5129;
color: #ffffff;
}
.upstream-data-button:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(133, 102, 50, 0.2);
}
.upstream-data-button .material-icons-outlined {
font-size: 1.25rem;
}
.upstream-data-alert {
display: flex;
align-items: flex-start;
gap: 0.625rem;
margin-bottom: 1rem;
padding: 0.75rem 1rem;
border: 1px solid var(--vip-danger, #b80000);
border-radius: 0.5rem;
background: #fff5f5;
color: var(--vip-danger, #b80000);
}
.upstream-data-alert .material-icons-outlined {
flex: 0 0 auto;
font-size: 1.25rem;
}
.upstream-data-summary {
display: grid;
grid-template-columns: repeat(
var(--upstream-summary-columns, 3),
minmax(var(--upstream-summary-min-width, 10rem), 1fr)
);
margin: 0 0 1rem;
overflow: hidden;
border: 1px solid var(--vip-border, #d8d2c7);
border-radius: 0.75rem;
background: var(--vip-surface, #ffffff);
}
.upstream-data-summary--inventory {
--upstream-summary-columns: 5;
--upstream-summary-min-width: 8rem;
}
.upstream-data-summary div {
min-width: 0;
padding: 0.75rem 1rem;
border-right: 1px solid var(--vip-border, #d8d2c7);
}
.upstream-data-summary div:last-child {
border-right: 0;
}
.upstream-data-summary dt {
margin-bottom: 0.25rem;
color: var(--vip-content-muted, #595959);
font-size: 0.75rem;
font-weight: 500;
}
.upstream-data-summary dd {
overflow: hidden;
margin: 0;
color: var(--vip-content-ink, #171717);
font-size: 1.375rem;
font-variant-numeric: tabular-nums;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.upstream-data-table-panel {
overflow: hidden;
border: 1px solid var(--vip-border, #d8d2c7);
border-radius: 0.75rem;
background: var(--vip-surface, #ffffff);
}
.upstream-data-table-scroll {
overflow-x: auto;
scrollbar-color: var(--vip-content-accent, #b9954f) #efede8;
scrollbar-width: thin;
}
.upstream-data-table {
width: 100%;
min-width: 68rem;
border-collapse: collapse;
font-size: 0.78125rem;
font-variant-numeric: tabular-nums;
}
.upstream-data-table--inventory {
min-width: 108rem;
}
.upstream-data-table--reconciliation {
min-width: 122rem;
}
.upstream-data-table th,
.upstream-data-table td {
height: 2.5rem;
padding: 0.5rem 0.625rem;
border-right: 1px solid var(--vip-border, #d8d2c7);
border-bottom: 1px solid var(--vip-border, #d8d2c7);
text-align: left;
white-space: nowrap;
}
.upstream-data-table th:last-child,
.upstream-data-table td:last-child {
border-right: 0;
}
.upstream-data-table thead th {
background: var(--vip-content-accent-soft, #f7f0de);
color: var(--vip-content-ink, #171717);
font-size: 0.75rem;
font-weight: 600;
}
.upstream-data-table tbody tr:hover {
background: #fbf8f0;
}
.upstream-data-table .is-number {
text-align: right;
}
.upstream-data-table .is-empty td {
height: 8rem;
color: var(--vip-content-muted, #595959);
text-align: center;
}
.upstream-data-pagination {
display: flex;
min-height: 3rem;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.625rem 1rem;
color: var(--vip-content-muted, #595959);
font-size: 0.78125rem;
}
.upstream-data-pagination__controls .upstream-data-button {
width: 2.125rem;
padding: 0;
}
.upstream-data-pagination__controls .is-disabled {
pointer-events: none;
opacity: 0.45;
}
@media (max-width: 900px) {
.upstream-data-summary--inventory {
--upstream-summary-columns: 2;
}
.upstream-data-summary--inventory div {
border-bottom: 1px solid var(--vip-border, #d8d2c7);
}
.upstream-data-summary--inventory div:nth-child(2n) {
border-right: 0;
}
.upstream-data-summary--inventory div:last-child {
border-bottom: 0;
}
}
@media (max-width: 720px) {
.upstream-data-field,
.upstream-data-actions,
.upstream-data-actions .upstream-data-button {
width: 100%;
flex-basis: 100%;
}
.upstream-data-summary {
--upstream-summary-columns: 1;
}
.upstream-data-summary div,
.upstream-data-summary--inventory div:nth-child(2n) {
border-right: 0;
border-bottom: 1px solid var(--vip-border, #d8d2c7);
}
.upstream-data-summary div:last-child {
border-bottom: 0;
}
.upstream-data-pagination {
align-items: flex-start;
flex-direction: column;
}
}
</style>

View File

@ -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
<div class="upstream-data-page">
@include('business.pages.partials.upstream-data-styles')
@if ($error)
<div class="upstream-data-alert" role="alert">
<span class="material-icons-outlined" aria-hidden="true">error_outline</span>
<span>{{ $error }}</span>
</div>
@endif
<form class="upstream-data-toolbar" method="get" action="{{ request()->url() }}" aria-label="素板价格筛选">
<div class="upstream-data-field">
<label for="raw-board-price-name">原料名称</label>
<input id="raw-board-price-name" name="raw_name" type="text" value="{{ $filters['raw_name'] ?? '' }}" placeholder="请输入原料名称">
</div>
<div class="upstream-data-field">
<label for="raw-board-price-code">原料编号</label>
<input id="raw-board-price-code" name="raw_code" type="text" value="{{ $filters['raw_code'] ?? '' }}" placeholder="请输入原料编号">
</div>
<div class="upstream-data-field">
<label for="raw-board-production-code">生产编号</label>
<input id="raw-board-production-code" name="production_code" type="text" value="{{ $filters['production_code'] ?? '' }}" placeholder="请输入生产编号">
</div>
<div class="upstream-data-field">
<label for="raw-board-price-supplier">目标厂商</label>
<input id="raw-board-price-supplier" name="supplier" type="text" value="{{ $filters['supplier'] ?? '' }}" placeholder="请输入目标厂商">
</div>
<div class="upstream-data-actions">
<button class="upstream-data-button upstream-data-button--primary" type="submit">
<span class="material-icons-outlined" aria-hidden="true">search</span>
<span>查询</span>
</button>
<a class="upstream-data-button" href="{{ request()->url() }}" title="清除筛选条件">
<span class="material-icons-outlined" aria-hidden="true">restart_alt</span>
<span>重置</span>
</a>
</div>
</form>
<div class="upstream-data-table-panel">
<div class="upstream-data-table-scroll">
<table class="upstream-data-table">
<thead>
<tr>
<th>价格类型</th>
<th>原料名称</th>
<th>原料编号</th>
<th>生产编号</th>
<th class="is-number">最新价格</th>
<th>目标厂商</th>
<th>厂商类型</th>
</tr>
</thead>
<tbody>
@forelse ($rows as $row)
<tr>
<td>{{ $row['price_type'] ?: '-' }}</td>
<td>{{ $row['raw_name'] ?: '-' }}</td>
<td>{{ $row['raw_code'] ?: '-' }}</td>
<td>{{ $row['production_code'] ?: '-' }}</td>
<td class="is-number">{{ number_format((float) $row['latest_price'], 2) }}</td>
<td>{{ $row['target_supplier'] ?: '-' }}</td>
<td>{{ $row['factory_type'] ?: '-' }}</td>
</tr>
@empty
<tr class="is-empty">
<td colspan="7">{{ $error ? '数据源暂不可用,请稍后重试。' : '当前筛选条件下没有匹配的素板价格。' }}</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="upstream-data-pagination">
<span>
共 {{ number_format($total) }} 条记录
@if ($dataStatus)
· 数据状态 {{ $dataStatus }}
@endif
</span>
<div class="upstream-data-pagination__controls" aria-label="素板价格分页">
@if ($currentPage <= 1)
<span class="upstream-data-button is-disabled" aria-disabled="true" title="上一页"><span class="material-icons-outlined" aria-hidden="true">chevron_left</span></span>
@else
<a class="upstream-data-button" href="{{ request()->fullUrlWithQuery(['data_page' => $currentPage - 1]) }}" aria-label="上一页" title="上一页"><span class="material-icons-outlined" aria-hidden="true">chevron_left</span></a>
@endif
<span>第 {{ $currentPage }} / {{ max($lastPage, 1) }} 页</span>
@if ($currentPage >= $lastPage)
<span class="upstream-data-button is-disabled" aria-disabled="true" title="下一页"><span class="material-icons-outlined" aria-hidden="true">chevron_right</span></span>
@else
<a class="upstream-data-button" href="{{ request()->fullUrlWithQuery(['data_page' => $currentPage + 1]) }}" aria-label="下一页" title="下一页"><span class="material-icons-outlined" aria-hidden="true">chevron_right</span></a>
@endif
</div>
</div>
</div>
</div>

View File

@ -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
<div class="upstream-data-page">
@include('business.pages.partials.upstream-data-styles')
@if ($error)
<div class="upstream-data-alert" role="alert">
<span class="material-icons-outlined" aria-hidden="true">error_outline</span>
<span>{{ $error }}</span>
</div>
@endif
<form class="upstream-data-toolbar" method="get" action="{{ request()->url() }}" aria-label="素板对账筛选">
<div class="upstream-data-field">
<label for="raw-board-detail-date-from">入库日期从</label>
<input id="raw-board-detail-date-from" name="date_from" type="date" value="{{ $filters['date_from'] ?? '' }}">
</div>
<div class="upstream-data-field">
<label for="raw-board-detail-date-to">入库日期至</label>
<input id="raw-board-detail-date-to" name="date_to" type="date" value="{{ $filters['date_to'] ?? '' }}">
</div>
<div class="upstream-data-field">
<label for="raw-board-detail-production">原料生产编号</label>
<input id="raw-board-detail-production" name="production_code" type="text" value="{{ $filters['production_code'] ?? '' }}" placeholder="请输入生产编号">
</div>
<div class="upstream-data-field">
<label for="raw-board-detail-name">原料名称</label>
<input id="raw-board-detail-name" name="raw_name" type="text" value="{{ $filters['raw_name'] ?? '' }}" placeholder="请输入原料名称">
</div>
<div class="upstream-data-field">
<label for="raw-board-detail-return">退货标识</label>
<select id="raw-board-detail-return" name="return_flag">
<option value="">全部</option>
<option value="normal" @selected(($filters['return_flag'] ?? '') === 'normal')>正常出库</option>
<option value="return" @selected(($filters['return_flag'] ?? '') === 'return')>退货</option>
</select>
</div>
<div class="upstream-data-field">
<label for="raw-board-detail-outbound-supplier">出库厂商</label>
<input id="raw-board-detail-outbound-supplier" name="outbound_supplier" type="text" value="{{ $filters['outbound_supplier'] ?? '' }}" placeholder="请输入出库厂商">
</div>
<div class="upstream-data-field">
<label for="raw-board-detail-arrival-supplier">到货厂商</label>
<input id="raw-board-detail-arrival-supplier" name="arrival_supplier" type="text" value="{{ $filters['arrival_supplier'] ?? '' }}" placeholder="请输入到货厂商">
</div>
<div class="upstream-data-field">
<label for="raw-board-detail-outbound-no">出库单号</label>
<input id="raw-board-detail-outbound-no" name="outbound_no" type="text" value="{{ $filters['outbound_no'] ?? '' }}" placeholder="请输入出库单号或送货号">
</div>
<div class="upstream-data-actions">
<button class="upstream-data-button upstream-data-button--primary" type="submit">
<span class="material-icons-outlined" aria-hidden="true">search</span>
<span>查询</span>
</button>
<a class="upstream-data-button" href="{{ request()->url() }}" title="清除筛选条件">
<span class="material-icons-outlined" aria-hidden="true">restart_alt</span>
<span>重置</span>
</a>
</div>
</form>
<dl class="upstream-data-summary" aria-label="素板对账汇总">
<div>
<dt>出库数量</dt>
<dd>{{ number_format((float) ($totals['outbound_quantity'] ?? 0), 2) }}</dd>
</div>
<div>
<dt>实收数量</dt>
<dd>{{ number_format((float) ($totals['received_quantity'] ?? 0), 2) }}</dd>
</div>
<div>
<dt>对账金额</dt>
<dd>{{ number_format((float) ($totals['reconciliation_amount'] ?? 0), 2) }}</dd>
</div>
</dl>
<div class="upstream-data-table-panel">
<div class="upstream-data-table-scroll">
<table class="upstream-data-table upstream-data-table--reconciliation">
<thead>
<tr>
<th>入库日期</th>
<th>出库日期</th>
<th>原料生产编号</th>
<th>原料名称</th>
<th class="is-number">出库数量</th>
<th>单位</th>
<th>退货标识</th>
<th>出库厂商</th>
<th>到货厂商</th>
<th>批次日期</th>
<th>出库单号</th>
<th>出库备注</th>
<th>申领单号</th>
<th>单号类型</th>
<th>送货单号</th>
<th class="is-number">实收数量</th>
<th class="is-number">单价</th>
<th class="is-number">对账金额</th>
<th class="is-number">运费(包含分摊运费)</th>
<th class="is-number">分摊运费</th>
<th class="is-number">运费</th>
</tr>
</thead>
<tbody>
@forelse ($rows as $row)
<tr>
<td>{{ $row['inbound_date'] ?: '-' }}</td>
<td>{{ $row['outbound_date'] ?: '-' }}</td>
<td>{{ $row['production_code'] ?: '-' }}</td>
<td>{{ $row['raw_name'] ?: '-' }}</td>
<td class="is-number">{{ $number($row['outbound_quantity'] ?? null) }}</td>
<td>{{ $row['unit'] ?: '-' }}</td>
<td>{{ $row['return_flag'] ?: '-' }}</td>
<td>{{ $row['outbound_supplier'] ?: '-' }}</td>
<td>{{ $row['arrival_supplier'] ?: '-' }}</td>
<td>{{ $row['batch_date'] ?: '-' }}</td>
<td>{{ $row['outbound_no'] ?: '-' }}</td>
<td>{{ $row['outbound_remark'] ?: '-' }}</td>
<td>{{ $row['requisition_no'] ?: '-' }}</td>
<td>{{ $row['number_type'] ?: '-' }}</td>
<td>{{ $row['delivery_no'] ?: '-' }}</td>
<td class="is-number">{{ $number($row['received_quantity'] ?? null) }}</td>
<td class="is-number">{{ $number($row['unit_price'] ?? null) }}</td>
<td class="is-number">{{ $number($row['reconciliation_amount'] ?? null) }}</td>
<td class="is-number">{{ $number($row['freight_with_allocation'] ?? null) }}</td>
<td class="is-number">{{ $number($row['allocated_freight'] ?? null) }}</td>
<td class="is-number">{{ $number($row['freight'] ?? null) }}</td>
</tr>
@empty
<tr class="is-empty">
<td colspan="21">{{ $error ? '数据源暂不可用,请稍后重试。' : '当前筛选条件下没有匹配的素板对账数据。' }}</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="upstream-data-pagination">
<span>
共 {{ number_format($total) }} 条记录
@if ($dataStatus)
· 数据状态 {{ $dataStatus }}
@endif
</span>
<div class="upstream-data-pagination__controls" aria-label="素板对账分页">
@if ($currentPage <= 1)
<span class="upstream-data-button is-disabled" aria-disabled="true" title="上一页"><span class="material-icons-outlined" aria-hidden="true">chevron_left</span></span>
@else
<a class="upstream-data-button" href="{{ request()->fullUrlWithQuery(['data_page' => $currentPage - 1]) }}" aria-label="上一页" title="上一页"><span class="material-icons-outlined" aria-hidden="true">chevron_left</span></a>
@endif
<span>第 {{ $currentPage }} / {{ max($lastPage, 1) }} 页</span>
@if ($currentPage >= $lastPage)
<span class="upstream-data-button is-disabled" aria-disabled="true" title="下一页"><span class="material-icons-outlined" aria-hidden="true">chevron_right</span></span>
@else
<a class="upstream-data-button" href="{{ request()->fullUrlWithQuery(['data_page' => $currentPage + 1]) }}" aria-label="下一页" title="下一页"><span class="material-icons-outlined" aria-hidden="true">chevron_right</span></a>
@endif
</div>
</div>
</div>
</div>

View File

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

View File

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

View File

@ -0,0 +1,65 @@
<?php
namespace Tests\Feature\Installation;
use App\Console\Commands\VerifySystemDatabase;
use App\Utilities\SystemDatabaseConfiguration;
use Mockery;
use RuntimeException;
use Tests\TestCase;
class VerifySystemDatabaseTest extends TestCase
{
public function test_verify_command_reports_configuration_not_loaded(): void
{
$configuration = Mockery::mock(SystemDatabaseConfiguration::class);
$configuration->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,
]);
}
}

View File

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

View File

@ -0,0 +1,92 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Modules\EcommerceFinance\Repositories\InventoryRepository;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
use PHPUnit\Framework\TestCase;
class InventoryRepositoryTest extends TestCase
{
public function test_board_inventory_uses_latest_raw_board_snapshot_and_calculates_amounts(): 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].[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']);
}
}

View File

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

View File

@ -0,0 +1,121 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Modules\EcommerceFinance\Repositories\RawBoardPayablesRepository;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
use PHPUnit\Framework\TestCase;
class RawBoardPayablesRepositoryTest extends TestCase
{
public function test_prices_use_current_purchase_price_and_latest_material_name(): 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].[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]);
}
}

View File

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