677 lines
27 KiB
PHP
677 lines
27 KiB
PHP
<?php
|
|
|
|
namespace Modules\EcommerceFinance\Repositories;
|
|
|
|
use Carbon\CarbonInterface;
|
|
use Generator;
|
|
use Illuminate\Database\ConnectionInterface;
|
|
use Illuminate\Database\DatabaseManager;
|
|
use Illuminate\Database\Query\Builder;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use RuntimeException;
|
|
use Throwable;
|
|
|
|
/**
|
|
* 三方仓仓储数据的共享业务镜像只读查询。
|
|
*
|
|
* 当前库存是每个 SKU 独立的时间锚点。历史期初、期末均通过已确认
|
|
* 流水的 qty * qty_type 从该锚点回推或前推,页面请求不直连上游。
|
|
*/
|
|
class ThirdPartyWarehouseStorageMirrorRepository
|
|
{
|
|
public const DATASET_KEY = 'jiansun.third-party-warehouse-storage';
|
|
|
|
public const WAREHOUSE_ID = 15045364;
|
|
|
|
private const STATE_TABLE = 'third_party_warehouse_sync_states';
|
|
|
|
private const HEADER_TABLE = 'third_party_warehouse_inout_headers';
|
|
|
|
private const ITEM_TABLE = 'third_party_warehouse_inout_items';
|
|
|
|
private const INVENTORY_TABLE = 'third_party_warehouse_inventory_snapshots';
|
|
|
|
private const PRODUCT_TABLE = 'ops_jst_sku';
|
|
|
|
private const PRICE_CHANGE_TABLE = 'js_pm_price_adjustment_log';
|
|
|
|
private const GENERIC_STATE_TABLE = 'business_mirror_table_states';
|
|
|
|
private const PRODUCT_DATASET_KEY = 'operations_replica.rpt.jst_sku';
|
|
|
|
private const PRICE_CHANGE_DATASET_KEY = 'jiansun.dbo.pm_priceAdjustment_log';
|
|
|
|
/** @var array<string,string> */
|
|
private const MOVEMENT_COLUMNS = [
|
|
'processing_in' => '加工进仓',
|
|
'processing_out' => '加工出仓',
|
|
'purchase_in' => '采购进仓',
|
|
'purchase_return' => '采购退货',
|
|
'other_in' => '其它进仓',
|
|
'other_return' => '其它退货',
|
|
'sales_out' => '销售出仓',
|
|
'sales_return' => '销售退货',
|
|
'stocktake' => '盘点',
|
|
'transfer_out' => '调拨出',
|
|
'transfer_in' => '调拨入',
|
|
'other_out' => '其它出库',
|
|
];
|
|
|
|
/** @var list<string> */
|
|
private const OUTPUT_COLUMNS = [
|
|
'owner', 'sku_code', 'image_url', 'product_name', 'short_name',
|
|
'supplier', 'category', 'virtual_category', 'supplier_style_no',
|
|
'color_spec', 'brand', 'unit', 'opening_qty', 'closing_qty',
|
|
'processing_in', 'processing_out', 'purchase_in', 'purchase_return',
|
|
'other_in', 'other_return', 'sales_out', 'sales_return', 'stocktake',
|
|
'transfer_out', 'transfer_in', 'opening_aux', 'other_out',
|
|
'turnover_days', 'opening_amount', 'closing_amount', 'price_type',
|
|
'order_type', 'other_price_3', 'other_price_4', 'package_count',
|
|
'model_spec', 'board_type', 'purchase_type', 'other_attribute_4',
|
|
'production_status', 'other_attribute_6', 'item_no',
|
|
'single_double_package', 'drawing_id', 'material',
|
|
];
|
|
|
|
private readonly ConnectionInterface $connection;
|
|
|
|
public function __construct(DatabaseManager $databases)
|
|
{
|
|
$this->connection = $databases->connection(
|
|
(string) config('upstream_mirrors.connection', 'business_mirror'),
|
|
);
|
|
}
|
|
|
|
public function available(): bool
|
|
{
|
|
try {
|
|
$schema = Schema::connection($this->connection->getName());
|
|
|
|
foreach ([
|
|
self::STATE_TABLE,
|
|
self::HEADER_TABLE,
|
|
self::ITEM_TABLE,
|
|
self::INVENTORY_TABLE,
|
|
self::PRODUCT_TABLE,
|
|
self::PRICE_CHANGE_TABLE,
|
|
self::GENERIC_STATE_TABLE,
|
|
] as $table) {
|
|
if (! $schema->hasTable($table)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return $schema->hasColumn(self::ITEM_TABLE, 'qty_type')
|
|
&& $schema->hasColumn(self::HEADER_TABLE, 'source_modified_at')
|
|
&& $schema->hasColumn(self::ITEM_TABLE, 'source_modified_at')
|
|
&& $schema->hasColumn(self::INVENTORY_TABLE, 'source_modified_at')
|
|
&& $schema->hasColumn(self::INVENTORY_TABLE, 'captured_at');
|
|
} catch (Throwable) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public function ready(): bool
|
|
{
|
|
$state = $this->state();
|
|
|
|
return is_array($state)
|
|
&& ($state['initial_completed_at'] ?? null) !== null
|
|
&& $this->generation($state, 'active_header_generation') !== null
|
|
&& $this->generation($state, 'active_item_generation') !== null
|
|
&& $this->generation($state, 'active_inventory_generation') !== null
|
|
&& $this->dependenciesReady();
|
|
}
|
|
|
|
/** @return array<string,mixed>|null */
|
|
public function state(): ?array
|
|
{
|
|
if (! $this->available()) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
$row = $this->connection->table(self::STATE_TABLE)
|
|
->where('dataset_key', self::DATASET_KEY)
|
|
->first();
|
|
|
|
return $row === null ? null : (array) $row;
|
|
} catch (Throwable) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed>|null $state
|
|
* @return array{code:string,last_succeeded_at:?string,lag_minutes:?int}
|
|
*/
|
|
public function syncStatus(?array $state = null, ?CarbonInterface $at = null): array
|
|
{
|
|
$state ??= $this->state();
|
|
if (! is_array($state)) {
|
|
return ['code' => 'unavailable', 'last_succeeded_at' => null, 'lag_minutes' => null];
|
|
}
|
|
|
|
$lastSucceededAt = $this->timestamp($state['last_succeeded_at'] ?? null);
|
|
$lastSucceeded = $lastSucceededAt === null
|
|
? null
|
|
: Carbon::createFromTimestamp($lastSucceededAt)->toDateTimeString();
|
|
$lagMinutes = $lastSucceededAt === null
|
|
? null
|
|
: (int) ceil(max(0, ($at ?? now())->getTimestamp() - $lastSucceededAt) / 60);
|
|
$status = (string) ($state['status'] ?? 'pending');
|
|
|
|
if (($state['initial_completed_at'] ?? null) === null) {
|
|
$code = $status === 'failed'
|
|
? 'initial_failed'
|
|
: ($status === 'running' ? 'initializing' : 'pending');
|
|
} elseif (! $this->dependenciesReady()) {
|
|
$code = 'dependency_pending';
|
|
} elseif ($status === 'failed') {
|
|
$code = 'failed';
|
|
} elseif ($status === 'running') {
|
|
$code = ($state['run_mode'] ?? null) === 'full' ? 'reconciling' : 'syncing';
|
|
} elseif ($lagMinutes === null
|
|
|| $lagMinutes > max((int) config(
|
|
'upstream_mirrors.third_party_warehouse_storage.stale_after_minutes',
|
|
1560,
|
|
), 1)) {
|
|
$code = 'delayed';
|
|
} else {
|
|
$code = 'healthy';
|
|
}
|
|
|
|
return [
|
|
'code' => $code,
|
|
'last_succeeded_at' => $lastSucceeded,
|
|
'lag_minutes' => $lagMinutes,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $filters
|
|
* @return array{data:list<array<string,mixed>>,total:int,summary:array<string,int|float>,state:array<string,mixed>,sync:array<string,mixed>}
|
|
*/
|
|
public function paginate(array $filters, int $page, int $perPage): array
|
|
{
|
|
$state = $this->requireReadyState();
|
|
$page = max($page, 1);
|
|
$perPage = min(max($perPage, 1), 100);
|
|
$query = $this->filteredReportQuery($filters, $state);
|
|
$summary = $this->summarizeQuery(clone $query);
|
|
$rows = $query->orderBy('sku_code')
|
|
->forPage($page, $perPage)
|
|
->get()
|
|
->map(fn (object $row): array => $this->mapRow($row))
|
|
->all();
|
|
|
|
return [
|
|
'data' => $rows,
|
|
'total' => $summary['count'],
|
|
'summary' => $summary,
|
|
'state' => $state,
|
|
'sync' => $this->syncStatus($state),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $filters
|
|
* @return array{count:int,opening_qty:float,closing_qty:float,opening_amount:float,closing_amount:float}
|
|
*/
|
|
public function summary(array $filters): array
|
|
{
|
|
$state = $this->requireReadyState();
|
|
|
|
return $this->summarizeQuery($this->filteredReportQuery($filters, $state));
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $filters
|
|
* @return array{suppliers:list<string>,categories:list<string>}
|
|
*/
|
|
public function filterOptions(array $filters = []): array
|
|
{
|
|
$state = $this->requireReadyState();
|
|
$query = $this->filteredReportQuery($filters, $state);
|
|
|
|
$suppliers = (clone $query)->whereNotNull('supplier')->where('supplier', '!=', '')
|
|
->distinct()->orderBy('supplier')->pluck('supplier')
|
|
->map(static fn (mixed $value): string => (string) $value)->all();
|
|
$categories = (clone $query)->whereNotNull('category')->where('category', '!=', '')
|
|
->distinct()->orderBy('category')->pluck('category')
|
|
->map(static fn (mixed $value): string => (string) $value)->all();
|
|
|
|
return ['suppliers' => $suppliers, 'categories' => $categories];
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $filters
|
|
* @return Generator<int,array<string,mixed>>
|
|
*/
|
|
public function exportRows(array $filters, int $batchSize = 500): Generator
|
|
{
|
|
$state = $this->requireReadyState();
|
|
$batchSize = min(max($batchSize, 1), 5000);
|
|
$lastSku = null;
|
|
|
|
while (true) {
|
|
$query = $this->filteredReportQuery($filters, $state);
|
|
if ($lastSku !== null) {
|
|
$query->where('sku_code', '>', $lastSku);
|
|
}
|
|
|
|
$rows = $query->orderBy('sku_code')->limit($batchSize)->get();
|
|
if ($rows->isEmpty()) {
|
|
return;
|
|
}
|
|
|
|
foreach ($rows as $row) {
|
|
yield $this->mapRow($row);
|
|
}
|
|
|
|
$lastSku = (string) $rows->last()->sku_code;
|
|
if ($rows->count() < $batchSize) {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
/** @param array<string,mixed> $state */
|
|
private function filteredReportQuery(array $filters, array $state): Builder
|
|
{
|
|
$query = $this->connection->query()->fromSub(
|
|
$this->reportQuery($filters, $state),
|
|
'report',
|
|
);
|
|
|
|
$this->containsFilter($query, 'sku_code', $filters['sku_code'] ?? '');
|
|
$this->containsFilter($query, 'product_name', $filters['product_name'] ?? '');
|
|
$this->containsFilter($query, 'supplier', $filters['supplier'] ?? '');
|
|
$this->containsFilter($query, 'category', $filters['category'] ?? '');
|
|
|
|
$nonZero = ['opening_qty', 'closing_qty', ...array_keys(self::MOVEMENT_COLUMNS)];
|
|
$query->where(function (Builder $values) use ($nonZero): void {
|
|
foreach ($nonZero as $index => $column) {
|
|
$method = $index === 0 ? 'where' : 'orWhere';
|
|
$values->{$method}($column, '<>', 0);
|
|
}
|
|
});
|
|
|
|
return $query;
|
|
}
|
|
|
|
/** @param array<string,mixed> $state */
|
|
private function reportQuery(array $filters, array $state): Builder
|
|
{
|
|
$period = $this->period($filters);
|
|
$headerGeneration = $this->generation($state, 'active_header_generation');
|
|
$itemGeneration = $this->generation($state, 'active_item_generation');
|
|
$inventoryGeneration = $this->generation($state, 'active_inventory_generation');
|
|
$anchorAt = $this->stateAnchor($state);
|
|
|
|
if ($headerGeneration === null || $itemGeneration === null || $inventoryGeneration === null) {
|
|
throw new RuntimeException('third_party_warehouse_mirror_not_ready');
|
|
}
|
|
|
|
$skuRangeStart = min($period['start'], $anchorAt);
|
|
$skuRangeEnd = max($period['end'], $anchorAt);
|
|
$movementSkus = $this->confirmedMovements($headerGeneration, $itemGeneration)
|
|
->where('h.occurred_at', '>=', $skuRangeStart)
|
|
->where('h.occurred_at', '<', $skuRangeEnd)
|
|
->select('i.sku_code');
|
|
$skuUnion = $this->connection->table(self::INVENTORY_TABLE . ' as inventory_skus')
|
|
->where('inventory_skus.generation', $inventoryGeneration)
|
|
->where('inventory_skus.wms_co_id', self::WAREHOUSE_ID)
|
|
->whereNotNull('inventory_skus.sku_code')
|
|
->where('inventory_skus.sku_code', '!=', '')
|
|
->select('inventory_skus.sku_code')
|
|
->union($movementSkus);
|
|
|
|
$anchors = $this->connection->query()->fromSub($skuUnion, 'sku_set')
|
|
->leftJoin(self::INVENTORY_TABLE . ' as inventory', function ($join) use ($inventoryGeneration): void {
|
|
$join->on('inventory.sku_code', '=', 'sku_set.sku_code')
|
|
->where('inventory.generation', '=', $inventoryGeneration)
|
|
->where('inventory.wms_co_id', '=', self::WAREHOUSE_ID);
|
|
})
|
|
->select('sku_set.sku_code')
|
|
->selectRaw('COALESCE(inventory.qty, 0) AS snapshot_qty')
|
|
->selectRaw('COALESCE(inventory.captured_at, ?) AS snapshot_at', [$anchorAt]);
|
|
|
|
$signedQuantity = '(COALESCE(movement_qty, 0) * COALESCE(movement_qty_type, 0))';
|
|
$closingExpression = 'a.snapshot_qty + COALESCE(SUM(CASE '
|
|
. 'WHEN a.snapshot_at < ? AND mv.occurred_at > a.snapshot_at AND mv.occurred_at < ? THEN ' . $signedQuantity . ' '
|
|
. 'WHEN a.snapshot_at >= ? AND mv.occurred_at >= ? AND mv.occurred_at < a.snapshot_at THEN -' . $signedQuantity . ' '
|
|
. 'ELSE 0 END), 0)';
|
|
$allMovements = $this->confirmedMovements($headerGeneration, $itemGeneration)
|
|
->select([
|
|
'i.sku_code',
|
|
'i.qty as movement_qty',
|
|
'i.qty_type as movement_qty_type',
|
|
'h.occurred_at',
|
|
]);
|
|
$balances = $this->connection->query()->fromSub($anchors, 'a')
|
|
->leftJoinSub($allMovements, 'mv', 'mv.sku_code', '=', 'a.sku_code')
|
|
->groupBy('a.sku_code', 'a.snapshot_qty', 'a.snapshot_at')
|
|
->select('a.sku_code')
|
|
->selectRaw($closingExpression . ' AS closing_qty', [
|
|
$period['end'], $period['end'], $period['end'], $period['end'],
|
|
]);
|
|
|
|
$periodMovements = $this->confirmedMovements($headerGeneration, $itemGeneration)
|
|
->where('h.occurred_at', '>=', $period['start'])
|
|
->where('h.occurred_at', '<', $period['end'])
|
|
->groupBy('i.sku_code')
|
|
->select('i.sku_code');
|
|
foreach (self::MOVEMENT_COLUMNS as $column => $type) {
|
|
$periodMovements->selectRaw(
|
|
'COALESCE(SUM(CASE WHEN h.io_type = ? THEN '
|
|
. '(COALESCE(i.qty, 0) * COALESCE(i.qty_type, 0)) ELSE 0 END), 0) AS ' . $column,
|
|
[$type],
|
|
);
|
|
}
|
|
|
|
$latestProducts = $this->connection->table(self::PRODUCT_TABLE)
|
|
->where('sku_type', 'normal')
|
|
->whereNotNull('sku_id')->where('sku_id', '!=', '')
|
|
->groupBy('sku_id')->select('sku_id')->selectRaw('MAX(autoid) AS autoid');
|
|
$report = $this->connection->query()->fromSub($balances, 'b')
|
|
->leftJoinSub($periodMovements, 'm', 'm.sku_code', '=', 'b.sku_code')
|
|
->leftJoinSub($latestProducts, 'latest_product', 'latest_product.sku_id', '=', 'b.sku_code')
|
|
->leftJoin(self::PRODUCT_TABLE . ' as s', function ($join): void {
|
|
$join->on('s.sku_id', '=', 'latest_product.sku_id')
|
|
->on('s.autoid', '=', 'latest_product.autoid');
|
|
});
|
|
|
|
$movementSelects = [];
|
|
foreach (array_keys(self::MOVEMENT_COLUMNS) as $column) {
|
|
$movementSelects[$column] = 'COALESCE(m.' . $column . ', 0)';
|
|
}
|
|
$netMovement = implode(' + ', array_values($movementSelects));
|
|
$openingQuantity = '(b.closing_qty - (' . $netMovement . '))';
|
|
$openingPrice = $this->priceAtExpression($period['start'], true);
|
|
$closingPrice = $this->priceAtExpression($period['end'], false);
|
|
$owner = (string) config(
|
|
'upstream_mirrors.third_party_warehouse_storage.owner_name',
|
|
'常州简尚家居有限公司',
|
|
);
|
|
|
|
$report->selectRaw('? AS owner', [$owner])
|
|
->addSelect([
|
|
'b.sku_code',
|
|
$this->connection->raw('COALESCE(s.pic_big, s.pic) AS image_url'),
|
|
$this->connection->raw('s.name AS product_name'),
|
|
$this->connection->raw('COALESCE(s.short_name, s.name) AS short_name'),
|
|
$this->connection->raw('s.supplier_name AS supplier'),
|
|
$this->connection->raw('s.category AS category'),
|
|
$this->connection->raw('s.vc_name AS virtual_category'),
|
|
$this->connection->raw('s.supplier_sku_id AS supplier_style_no'),
|
|
$this->connection->raw('COALESCE(s.properties_value, s.color) AS color_spec'),
|
|
$this->connection->raw('s.brand AS brand'),
|
|
$this->connection->raw('s.unit AS unit'),
|
|
])
|
|
->selectRaw($openingQuantity . ' AS opening_qty')
|
|
->selectRaw('b.closing_qty AS closing_qty');
|
|
foreach ($movementSelects as $column => $expression) {
|
|
$report->selectRaw($expression . ' AS ' . $column);
|
|
}
|
|
|
|
$report->selectRaw('0 AS opening_aux')
|
|
->selectRaw(
|
|
'CASE WHEN COALESCE(m.sales_out, 0) = 0 THEN NULL '
|
|
. 'ELSE ROUND((1.0 * b.closing_qty / ABS(m.sales_out)) * ?, 0) END AS turnover_days',
|
|
[$period['days']],
|
|
)
|
|
->selectRaw(
|
|
'ROUND(' . $openingQuantity . ' * (' . $openingPrice['sql'] . '), 2) AS opening_amount',
|
|
$openingPrice['bindings'],
|
|
)
|
|
->selectRaw(
|
|
'ROUND(b.closing_qty * (' . $closingPrice['sql'] . '), 2) AS closing_amount',
|
|
$closingPrice['bindings'],
|
|
)
|
|
->addSelect([
|
|
$this->connection->raw('s.other_price_1 AS price_type'),
|
|
$this->connection->raw('s.other_price_2 AS order_type'),
|
|
$this->connection->raw('s.other_price_3 AS other_price_3'),
|
|
$this->connection->raw('s.other_price_4 AS other_price_4'),
|
|
$this->connection->raw('s.pack_qty AS package_count'),
|
|
$this->connection->raw('s.other_1 AS model_spec'),
|
|
$this->connection->raw('s.other_2 AS board_type'),
|
|
$this->connection->raw('s.other_3 AS purchase_type'),
|
|
$this->connection->raw('s.other_4 AS other_attribute_4'),
|
|
$this->connection->raw('s.other_5 AS production_status'),
|
|
$this->connection->raw('s.other_6 AS other_attribute_6'),
|
|
$this->connection->raw('s.other_7 AS item_no'),
|
|
$this->connection->raw('s.other_8 AS single_double_package'),
|
|
$this->connection->raw('s.other_9 AS drawing_id'),
|
|
$this->connection->raw('s.other_10 AS material'),
|
|
]);
|
|
|
|
return $report;
|
|
}
|
|
|
|
private function confirmedMovements(string $headerGeneration, string $itemGeneration): Builder
|
|
{
|
|
return $this->connection->table(self::ITEM_TABLE . ' as i')
|
|
->join(self::HEADER_TABLE . ' as h', function ($join) use ($headerGeneration): void {
|
|
$join->on('h.io_id', '=', 'i.io_id')
|
|
->where('h.generation', '=', $headerGeneration);
|
|
})
|
|
->where('i.generation', $itemGeneration)
|
|
->where('h.wms_co_id', self::WAREHOUSE_ID)
|
|
->where('h.status', 'Confirmed')
|
|
->whereIn('h.io_type', array_values(self::MOVEMENT_COLUMNS))
|
|
->whereNotExists(function (Builder $newer) use ($headerGeneration): void {
|
|
$newer->selectRaw('1')->from(self::HEADER_TABLE . ' as newer')
|
|
->where('newer.generation', $headerGeneration)
|
|
->whereColumn('newer.io_id', 'h.io_id')
|
|
->whereColumn('newer.id', '>', 'h.id');
|
|
})
|
|
->whereNotNull('i.sku_code')->where('i.sku_code', '!=', '');
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $filters
|
|
* @return array{dimension:string,value:string,start:string,end:string,days:int}
|
|
*/
|
|
private function period(array $filters): array
|
|
{
|
|
$dimension = ($filters['dimension'] ?? 'month') === 'day' ? 'day' : 'month';
|
|
$candidate = trim((string) ($filters['period'] ?? ''));
|
|
|
|
if ($dimension === 'day') {
|
|
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $candidate) !== 1) {
|
|
$candidate = trim((string) ($filters['date'] ?? ''));
|
|
}
|
|
try {
|
|
$start = preg_match('/^\d{4}-\d{2}-\d{2}$/', $candidate) === 1
|
|
? Carbon::createFromFormat('!Y-m-d', $candidate)
|
|
: now()->startOfDay();
|
|
} catch (Throwable) {
|
|
$start = now()->startOfDay();
|
|
}
|
|
|
|
return [
|
|
'dimension' => 'day',
|
|
'value' => $start->format('Y-m-d'),
|
|
'start' => $start->format('Y-m-d 00:00:00'),
|
|
'end' => $start->copy()->addDay()->format('Y-m-d 00:00:00'),
|
|
'days' => 1,
|
|
];
|
|
}
|
|
|
|
if (preg_match('/^\d{4}-\d{2}$/', $candidate) !== 1) {
|
|
$candidate = trim((string) ($filters['month'] ?? ''));
|
|
}
|
|
try {
|
|
$start = preg_match('/^\d{4}-\d{2}$/', $candidate) === 1
|
|
? Carbon::createFromFormat('!Y-m', $candidate)->startOfMonth()
|
|
: now()->startOfMonth();
|
|
} catch (Throwable) {
|
|
$start = now()->startOfMonth();
|
|
}
|
|
|
|
return [
|
|
'dimension' => 'month',
|
|
'value' => $start->format('Y-m'),
|
|
'start' => $start->format('Y-m-d 00:00:00'),
|
|
'end' => $start->copy()->addMonth()->format('Y-m-d 00:00:00'),
|
|
'days' => $start->daysInMonth,
|
|
];
|
|
}
|
|
|
|
/** @param array<string,mixed> $state */
|
|
private function stateAnchor(array $state): string
|
|
{
|
|
$timestamp = $this->timestamp($state['last_succeeded_at'] ?? null);
|
|
|
|
return $timestamp === null
|
|
? now()->toDateTimeString()
|
|
: Carbon::createFromTimestamp($timestamp)->toDateTimeString();
|
|
}
|
|
|
|
/** @return array<string,mixed> */
|
|
private function requireReadyState(): array
|
|
{
|
|
$state = $this->state();
|
|
if (! is_array($state)
|
|
|| ($state['initial_completed_at'] ?? null) === null
|
|
|| $this->generation($state, 'active_header_generation') === null
|
|
|| $this->generation($state, 'active_item_generation') === null
|
|
|| $this->generation($state, 'active_inventory_generation') === null
|
|
|| ! $this->dependenciesReady()) {
|
|
throw new RuntimeException('third_party_warehouse_mirror_not_ready');
|
|
}
|
|
|
|
return $state;
|
|
}
|
|
|
|
private function dependenciesReady(): bool
|
|
{
|
|
try {
|
|
$required = [self::PRODUCT_DATASET_KEY, self::PRICE_CHANGE_DATASET_KEY];
|
|
$ready = $this->connection->table(self::GENERIC_STATE_TABLE)
|
|
->whereIn('dataset_key', $required)
|
|
->whereNotNull('initial_completed_at')
|
|
->pluck('dataset_key')
|
|
->map(static fn (mixed $dataset): string => (string) $dataset)
|
|
->all();
|
|
|
|
return array_diff($required, $ready) === [];
|
|
} catch (Throwable) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** @return array{sql:string,bindings:list<mixed>} */
|
|
private function priceAtExpression(string $boundary, bool $opening): array
|
|
{
|
|
$table = self::PRICE_CHANGE_TABLE;
|
|
$parts = [];
|
|
$bindings = [];
|
|
|
|
if ($opening) {
|
|
$parts[] = "(SELECT exact_price.originalPrice FROM {$table} AS exact_price "
|
|
. 'WHERE exact_price.skucode = b.sku_code AND exact_price.priceType = ? '
|
|
. 'AND exact_price.effectStartTime = ? ORDER BY exact_price.Id DESC LIMIT 1)';
|
|
array_push($bindings, 1, $boundary);
|
|
}
|
|
|
|
$parts[] = "(SELECT prior_price.afterPrice FROM {$table} AS prior_price "
|
|
. 'WHERE prior_price.skucode = b.sku_code AND prior_price.priceType = ? '
|
|
. 'AND prior_price.effectStartTime < ? '
|
|
. 'ORDER BY prior_price.effectStartTime DESC, prior_price.Id DESC LIMIT 1)';
|
|
array_push($bindings, 1, $boundary);
|
|
$parts[] = "(SELECT future_price.originalPrice FROM {$table} AS future_price "
|
|
. 'WHERE future_price.skucode = b.sku_code AND future_price.priceType = ? '
|
|
. 'AND future_price.effectStartTime >= ? '
|
|
. 'ORDER BY future_price.effectStartTime ASC, future_price.Id ASC LIMIT 1)';
|
|
array_push($bindings, 1, $boundary);
|
|
$parts[] = 's.purchase_price';
|
|
|
|
return [
|
|
'sql' => 'COALESCE(' . implode(', ', $parts) . ')',
|
|
'bindings' => $bindings,
|
|
];
|
|
}
|
|
|
|
/** @param array<string,mixed> $state */
|
|
private function generation(array $state, string $key): ?string
|
|
{
|
|
$value = trim((string) ($state[$key] ?? ''));
|
|
|
|
return $value === '' ? null : $value;
|
|
}
|
|
|
|
private function timestamp(mixed $value): ?int
|
|
{
|
|
if ($value instanceof CarbonInterface) {
|
|
return $value->getTimestamp();
|
|
}
|
|
if (! is_string($value) || trim($value) === '') {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return Carbon::parse($value)->getTimestamp();
|
|
} catch (Throwable) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return array{count:int,opening_qty:float,closing_qty:float,opening_amount:float,closing_amount:float}
|
|
*/
|
|
private function summarizeQuery(Builder $query): array
|
|
{
|
|
$row = $query->selectRaw(
|
|
'COUNT(*) AS row_count, '
|
|
. 'COALESCE(SUM(opening_qty), 0) AS opening_qty, '
|
|
. 'COALESCE(SUM(closing_qty), 0) AS closing_qty, '
|
|
. 'COALESCE(SUM(opening_amount), 0) AS opening_amount, '
|
|
. 'COALESCE(SUM(closing_amount), 0) AS closing_amount',
|
|
)->first();
|
|
|
|
return [
|
|
'count' => (int) ($row->row_count ?? 0),
|
|
'opening_qty' => round((float) ($row->opening_qty ?? 0), 4),
|
|
'closing_qty' => round((float) ($row->closing_qty ?? 0), 4),
|
|
'opening_amount' => round((float) ($row->opening_amount ?? 0), 2),
|
|
'closing_amount' => round((float) ($row->closing_amount ?? 0), 2),
|
|
];
|
|
}
|
|
|
|
/** @return array<string,mixed> */
|
|
private function mapRow(object $row): array
|
|
{
|
|
$mapped = [];
|
|
foreach (self::OUTPUT_COLUMNS as $column) {
|
|
$mapped[$column] = $row->{$column} ?? null;
|
|
}
|
|
foreach (['opening_qty', 'closing_qty', ...array_keys(self::MOVEMENT_COLUMNS), 'opening_aux'] as $column) {
|
|
$mapped[$column] = round((float) ($mapped[$column] ?? 0), 4);
|
|
}
|
|
foreach (['opening_amount', 'closing_amount'] as $column) {
|
|
$mapped[$column] = $mapped[$column] === null ? null : round((float) $mapped[$column], 2);
|
|
}
|
|
$mapped['turnover_days'] = $mapped['turnover_days'] === null
|
|
? null
|
|
: (int) round((float) $mapped['turnover_days']);
|
|
|
|
return $mapped;
|
|
}
|
|
|
|
private function containsFilter(Builder $query, string $column, mixed $value): void
|
|
{
|
|
$value = trim((string) $value);
|
|
if ($value !== '') {
|
|
$query->whereRaw($column . " LIKE ? ESCAPE '!'", [$this->contains($value)]);
|
|
}
|
|
}
|
|
|
|
private function contains(string $value): string
|
|
{
|
|
return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $value) . '%';
|
|
}
|
|
}
|