合并:本地共享数据库验证与原材料数据页面
This commit is contained in:
parent
8a8ea93c63
commit
85f3db56ce
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
9
app/Exceptions/SystemDatabaseVerificationException.php
Normal file
9
app/Exceptions/SystemDatabaseVerificationException.php
Normal file
@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class SystemDatabaseVerificationException extends RuntimeException
|
||||
{
|
||||
}
|
||||
@ -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字段)
|
||||
*/
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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' => '封边条应付表'],
|
||||
|
||||
@ -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)"
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
143
modules/EcommerceFinance/Repositories/InventoryRepository.php
Normal file
143
modules/EcommerceFinance/Repositories/InventoryRepository.php
Normal 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)) . '%';
|
||||
}
|
||||
}
|
||||
@ -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];
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
65
tests/Feature/Installation/VerifySystemDatabaseTest.php
Normal file
65
tests/Feature/Installation/VerifySystemDatabaseTest.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
92
tests/Unit/EcommerceFinance/InventoryRepositoryTest.php
Normal file
92
tests/Unit/EcommerceFinance/InventoryRepositoryTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
121
tests/Unit/EcommerceFinance/RawBoardPayablesRepositoryTest.php
Normal file
121
tests/Unit/EcommerceFinance/RawBoardPayablesRepositoryTest.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user