CaiWuXiTong/modules/EcommerceFinance/Repositories/JushuitanCostPriceHistoryMirrorRepository.php

329 lines
11 KiB
PHP

<?php
namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Schema;
class JushuitanCostPriceHistoryMirrorRepository
{
public const DATASET_KEY = 'jushuitan.product-cost-price-operation-history';
public const TABLE = 'jushuitan_cost_price_history_rows';
public const STATE_TABLE = 'jushuitan_cost_price_history_sync_states';
private readonly ConnectionInterface $connection;
public function __construct(DatabaseManager $database)
{
$this->connection = $database->connection(
(string) config('upstream_mirrors.connection', 'business_mirror'),
);
}
public function available(): bool
{
$schema = Schema::connection($this->connection->getName());
return $schema->hasTable(self::STATE_TABLE)
&& $schema->hasTable(self::TABLE);
}
/** @return array<string,mixed>|null */
public function state(): ?array
{
if (! $this->available()) {
return null;
}
$row = $this->connection->table(self::STATE_TABLE)
->where('dataset_key', self::DATASET_KEY)
->first();
return $row === null ? null : (array) $row;
}
/** @return array{code:string,initialized:bool,last_succeeded_at:?string} */
public function syncStatus(): array
{
$state = $this->state();
if (! is_array($state)) {
return ['code' => 'unavailable', 'initialized' => false, 'last_succeeded_at' => null];
}
if (($state['initial_completed_at'] ?? null) === null) {
return [
'code' => match ((string) ($state['status'] ?? 'pending')) {
'running' => 'initializing',
'failed' => 'initial_failed',
default => 'pending',
},
'initialized' => false,
'last_succeeded_at' => null,
];
}
return [
'code' => ($state['status'] ?? null) === 'failed' ? 'failed' : 'ready',
'initialized' => true,
'last_succeeded_at' => ($state['last_succeeded_at'] ?? null) === null
? null
: (string) $state['last_succeeded_at'],
];
}
/** @return array<string,mixed> */
public function startRun(
string $mode,
string $generation,
Carbon $since,
Carbon $until,
bool $resume,
): array {
$now = now();
$values = [
'status' => 'running',
'run_mode' => $mode,
'generation' => $generation,
'last_attempted_at' => $now,
'last_error_code' => null,
'updated_at' => $now,
];
if (! $resume) {
$values += [
'window_started_at' => $since,
'window_ends_at' => $until,
'cursor_window_started_at' => $since,
'cursor_page' => 1,
'last_processed_source_rows' => 0,
'last_upserted_rows' => 0,
];
}
$query = $this->connection->table(self::STATE_TABLE)
->where('dataset_key', self::DATASET_KEY);
if ($query->exists()) {
$query->update($values);
} else {
$this->connection->table(self::STATE_TABLE)->insert(
['dataset_key' => self::DATASET_KEY, 'created_at' => $now] + $values,
);
}
return $this->state() ?? [];
}
/** @param list<array<string,mixed>> $records */
public function storePage(
array $records,
Carbon $cursorWindowStartedAt,
int $cursorPage,
string $generation,
int $processedSourceRows,
int $upsertedRows,
): void {
$now = now();
$mapped = array_map(function (array $record) use ($generation, $now): array {
$skuId = trim((string) ($record['sku_id'] ?? ''));
$values = [
'operation_id' => $this->operationId($record),
'sku_id' => $skuId,
'sku_id_normalized' => $this->normalizeSku($skuId),
'i_id' => $this->nullableString($record['i_id'] ?? null),
'product_name' => $this->nullableString($record['product_name'] ?? null),
'properties_value' => $this->nullableString($record['properties_value'] ?? null),
'sku_type' => $this->nullableString($record['sku_type'] ?? null),
'operation_type' => $this->nullableString($record['operation_type'] ?? null),
'operation_description' => $this->nullableString($record['operation_description'] ?? null),
'operator_name' => $this->nullableString($record['operator_name'] ?? null),
'operated_at' => $this->dateTime($record['operated_at'] ?? null),
'before_price' => $record['before_price'] ?? null,
'after_price' => $record['after_price'] ?? null,
'diff_amount' => $record['diff_amount'] ?? null,
];
return $values + [
'_mirror_generation' => $generation,
'row_hash' => hash('sha256', json_encode(
$values,
JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION | JSON_THROW_ON_ERROR,
)),
'_mirror_synced_at' => $now,
'created_at' => $now,
'updated_at' => $now,
];
}, $records);
$this->connection->transaction(function () use (
$mapped,
$cursorWindowStartedAt,
$cursorPage,
$generation,
$processedSourceRows,
$upsertedRows,
$now,
): void {
if ($mapped !== []) {
$this->connection->table(self::TABLE)->upsert(
$mapped,
['operation_id'],
[
'sku_id', 'sku_id_normalized', 'i_id', 'product_name',
'properties_value', 'sku_type', 'operation_type',
'operation_description', 'operator_name', 'operated_at',
'before_price', 'after_price', 'diff_amount',
'_mirror_generation', 'row_hash', '_mirror_synced_at', 'updated_at',
],
);
}
$this->connection->table(self::STATE_TABLE)
->where('dataset_key', self::DATASET_KEY)
->update([
'cursor_window_started_at' => $cursorWindowStartedAt,
'cursor_page' => $cursorPage,
'last_processed_source_rows' => $processedSourceRows,
'last_upserted_rows' => $upsertedRows,
'updated_at' => $now,
]);
});
}
public function complete(
string $mode,
string $generation,
Carbon $until,
int $processedSourceRows,
int $upsertedRows,
): void {
$now = now();
$this->connection->transaction(function () use (
$mode,
$generation,
$until,
$processedSourceRows,
$upsertedRows,
$now,
): void {
$values = [
'status' => 'succeeded',
'cursor_window_started_at' => null,
'cursor_page' => null,
'last_succeeded_at' => $until,
'last_processed_source_rows' => $processedSourceRows,
'last_upserted_rows' => $upsertedRows,
'mirror_row_count' => $this->connection->table(self::TABLE)->count(),
'last_error_code' => null,
'updated_at' => $now,
];
if ($mode === 'full') {
$values['initial_completed_at'] = $now;
$values['last_full_succeeded_at'] = $now;
}
$this->connection->table(self::STATE_TABLE)
->where('dataset_key', self::DATASET_KEY)
->update($values);
});
}
public function fail(string $errorCode): void
{
$this->connection->table(self::STATE_TABLE)
->where('dataset_key', self::DATASET_KEY)
->update([
'status' => 'failed',
'last_error_code' => mb_substr($errorCode, 0, 120),
'updated_at' => now(),
]);
}
/** @param list<string> $normalizedSkus @return Collection<string,Collection<int,object>> */
public function historyForSkus(array $normalizedSkus): Collection
{
if ($normalizedSkus === []) {
return collect();
}
return $this->connection->table(self::TABLE)
->whereIn('sku_id_normalized', $normalizedSkus)
->orderBy('sku_id_normalized')
->orderByDesc('operated_at')
->orderByDesc('operation_id')
->get([
'operation_id', 'sku_id', 'sku_id_normalized', 'operation_type',
'operation_description', 'operator_name', 'operated_at',
'before_price', 'after_price', 'diff_amount',
])
->groupBy('sku_id_normalized');
}
/** @return list<array<string,mixed>> */
public function formatHistory(Collection $records): array
{
return $records->map(static fn (object $record): array => [
'id' => $record->operation_id,
'source' => '聚水潭商品及库存管理',
'record_type_label' => '成本价变更',
'cost_price' => $record->after_price,
'before_price' => $record->before_price,
'after_price' => $record->after_price,
'diff_amount' => $record->diff_amount,
'effect_start_time' => $record->operated_at,
'effect_end_time' => null,
'remark' => $record->operation_description,
'created_at' => $record->operated_at,
'operator' => $record->operator_name,
'operation' => $record->operation_type,
])->values()->all();
}
private function normalizeSku(mixed $value): string
{
return mb_strtoupper(trim((string) $value));
}
/** @param array<string,mixed> $record */
private function operationId(array $record): string
{
$operationId = trim((string) ($record['operation_id'] ?? ''));
if ($operationId !== '') {
return $operationId;
}
return hash('sha256', json_encode([
$this->normalizeSku($record['sku_id'] ?? null),
$record['operation_description'] ?? null,
$record['operator_name'] ?? null,
$record['operated_at'] ?? null,
], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
}
private function nullableString(mixed $value): ?string
{
if ($value === null) {
return null;
}
$value = trim((string) $value);
return $value === '' ? null : $value;
}
private function dateTime(mixed $value): ?string
{
$value = $this->nullableString($value);
if ($value === null) {
return null;
}
try {
return Carbon::parse($value)->format('Y-m-d H:i:s.u');
} catch (\Throwable) {
return null;
}
}
}