CaiWuXiTong/modules/EcommerceFinance/Services/JushuitanOpenApiClient.php
limeng 8f150080c5 修正聚水潭成本价历史凭据边界与未同步状态 (#186)
保留完整成本价历史方案,区分经典接口凭据与新开放平台凭据;首次同步未完成时显示待同步,不误报 0 条。

验证:33 passed,260 assertions。
2026-09-15 11:39:09 +08:00

178 lines
6.2 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace Modules\EcommerceFinance\Services;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Http;
use RuntimeException;
class JushuitanOpenApiClient
{
/**
* @return array{records:list<array<string,mixed>>,source_rows:int,current_page:int,has_next:bool}
*/
public function getCostPriceOperationHistory(
Carbon $start,
Carbon $end,
int $page,
int $pageSize,
): array {
$payload = $this->request([
'page_index' => max($page, 1),
'page_size' => min(max($pageSize, 1), 50),
'modified_begin' => $start->format('Y-m-d H:i:s'),
'modified_end' => $end->format('Y-m-d H:i:s'),
]);
$records = is_array($payload['datas'] ?? null) ? $payload['datas'] : [];
return [
'records' => $this->normalizeCostPriceOperations($records),
'source_rows' => count($records),
'current_page' => max((int) ($payload['page_index'] ?? $page), 1),
'has_next' => (bool) ($payload['has_next'] ?? false),
];
}
/** @param array<string,mixed> $biz @return array<string,mixed> */
private function request(array $biz): array
{
$partnerId = trim((string) config('services.jushuitan.partner_id'));
$partnerKey = trim((string) config('services.jushuitan.partner_key'));
$token = trim((string) config('services.jushuitan.token'));
if ($partnerId === '' || $partnerKey === '' || $token === '') {
throw new RuntimeException('jushuitan_classic_api_credentials_missing');
}
$method = (string) config(
'services.jushuitan.product_operation_history_method',
'worklog.query',
);
$timestamp = (string) now()->timestamp;
// URL parameter order is part of the classic API signature contract.
$params = [
'method' => $method,
'partnerid' => $partnerId,
'token' => $token,
'ts' => $timestamp,
];
$params['sign'] = $this->sign($method, $partnerId, $token, $timestamp, $partnerKey);
$baseUrl = (string) config(
'services.jushuitan.base_url',
'https://open.erp321.com/api/open/query.aspx',
);
/** @var PendingRequest $request */
$request = Http::asJson()
->acceptJson()
->timeout(max((int) config('services.jushuitan.timeout', 20), 1));
$response = $request->post(
$baseUrl . '?' . http_build_query($params, '', '&', PHP_QUERY_RFC3986),
$biz,
);
if (! $response->successful()) {
throw new RuntimeException('jushuitan_api_http_' . $response->status());
}
$payload = $response->json();
if (! is_array($payload)) {
throw new RuntimeException('jushuitan_api_invalid_json');
}
$code = (int) ($payload['code'] ?? -1);
if ($code !== 0) {
throw new RuntimeException($this->errorCode($code, $payload));
}
return $payload;
}
/** @param array<string,mixed> $payload */
private function errorCode(int $code, array $payload): string
{
$message = mb_strtolower(trim((string) ($payload['msg'] ?? $payload['message'] ?? '')));
if ($code === 100 && str_contains($message, 'partnerid')) {
return 'jushuitan_classic_api_partner_id_invalid';
}
return 'jushuitan_api_code_' . $code;
}
private function sign(
string $method,
string $partnerId,
string $token,
string $timestamp,
string $partnerKey,
): string
{
return md5($method . $partnerId . 'token' . $token . 'ts' . $timestamp . $partnerKey);
}
/** @param mixed $records @return list<array<string,mixed>> */
private function normalizeCostPriceOperations(mixed $records): array
{
if (! is_array($records)) {
return [];
}
$result = [];
foreach ($records as $record) {
if (! is_array($record)) {
continue;
}
$skuId = trim((string) ($record['sku_id'] ?? ''));
$description = trim((string) ($record['remark'] ?? $record['operation_description'] ?? ''));
if ($skuId === '' || ! str_contains($description, '采购价')) {
continue;
}
[$beforePrice, $afterPrice] = $this->pricesFromDescription($description);
$result[] = [
'operation_id' => (string) ($record['autoid'] ?? ''),
'sku_id' => $skuId,
'i_id' => $this->nullableString($record['i_id'] ?? null),
'product_name' => $this->nullableString($record['name'] ?? null),
'properties_value' => $this->nullableString($record['properties_value'] ?? null),
'sku_type' => $this->nullableString($record['sku_type'] ?? null),
'operation_type' => $this->nullableString($record['type'] ?? $record['operation_type'] ?? null),
'operation_description' => $description,
'operator_name' => $this->nullableString($record['creator_name'] ?? null),
'operated_at' => $this->nullableString($record['created'] ?? null),
'before_price' => $beforePrice,
'after_price' => $afterPrice,
'diff_amount' => ($beforePrice === null || $afterPrice === null)
? null
: $afterPrice - $beforePrice,
];
}
return $result;
}
/** @return array{0:?float,1:?float} */
private function pricesFromDescription(string $description): array
{
$matched = preg_match(
'/采购价\s*[::]\s*(-?\d+(?:\.\d+)?)\s*(?:->|=>|→|至)\s*(-?\d+(?:\.\d+)?)/u',
$description,
$matches,
);
if ($matched !== 1) {
return [null, null];
}
return [(float) $matches[1], (float) $matches[2]];
}
private function nullableString(mixed $value): ?string
{
if ($value === null) {
return null;
}
$value = trim((string) $value);
return $value === '' ? null : $value;
}
}