329 lines
11 KiB
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;
|
|
}
|
|
}
|
|
}
|