'待审核', 'REJECT_AUDIT' => '已拒绝', 'WAIT_CONFIRM' => '待处理', 'CONFIRMING' => '处理中', 'CONFIRMED' => '已完成', ]; /** 记账原因:一级 => 已勾选的二级原因(与聚工单视图逐项一致)。 */ public const REASONS = [ '厂家记账' => [ '厂家成品发错货或漏发(面单照片,实物照片,条形码照片)', '缺货损失', '厂家双面单', '厂家缺货-公司替换款', '板材弯曲', '产品安装后摇晃', '退货运费(贴图详见同一快递单号另一笔记账)', '孔位错误', '配件超时未发', '厂家配件发错/漏发(提供证据图、协商无果图)', '板材爆口/掉漆/开裂/发霉/色差', '退货退款', ], '仓库记账' => [ '仓库双面单', '仓库发错货(面单照片,实物照片,条形码照片)', '审单原因(操作日志截图,交易截图)', '仓库未发出(仓库通知截图,交易截图)', '退货运费', ], ]; /** 聚工单只返回 fieldId,以下对应关系取自聚工单记账视图的列配置。 */ public const FIELD_REASON = 'SELECT_CASCADEField1413856620250906100920'; private const HEADER_FIELDS = [ 'bookkeeping_type' => 'SELECTField1413856620250906100658', 'source' => 'TEXTField1413856620250908100111', 'amount' => 'NUMBERField1413856620250906100530', 'remark' => 'TEXTField1413856620250906100940', 'attachment' => 'attachmentField', 'online_order_no' => 'orderField', 'internal_order_no' => 'o_id', 'actual_paid_amount' => 'actualPaidAmountField', 'carrier' => 'logisticsField', 'tracking_no' => 'lIdField', 'shop_name' => 'shopField', 'platform' => 'platform', 'shipping_warehouse' => 'wmsField', 'processing_status' => 'SELECTField1413856620251010103748', 'counterparty_agreed' => 'SELECTField1413856620251010103459', 'counterparty_note' => 'TEXTField1413856620251010103542', 'specialist_result' => 'SELECTField1413856620251010104200', 'specialist_note' => 'TEXTField1413856620251010104107', 'accepting_department' => 'SELECTField1413856620251122154718', 'order_shipped_at' => 'orderSendTime', 'header_supplier' => 'supplierField', ]; /** 商品明细子字段(同一工单多件商品时按行合并展示)。 */ private const ITEM_FIELDS = [ 'product_code' => ['skuId'], 'style_code' => ['iid'], 'other_attribute_6' => ['other6', 'other_6'], 'product_name' => ['name', 'skuName'], 'platform_sku_id' => ['platformItemSkuId'], 'platform_product_id' => ['platformItemId'], 'product_quantity' => ['qty'], 'product_price' => ['price'], 'product_amount' => ['priceCount'], 'buyer_paid_amount' => ['buyerPaidAmount'], 'supplier' => ['supplierName'], 'product_image' => ['pic'], ]; private const ROWS_CACHE_SECONDS = 600; private const QUERY_TIMEOUT_SECONDS = 60; private readonly ConnectionInterface $connection; public function __construct( private readonly BusinessMirrorDatasetRegistry $datasets, private readonly GenericBusinessMirrorRepository $mirror, ) { $this->connection = $mirror->connection(); } /** @return list 17 个“一级/二级”完整原因路径 */ public static function reasonPaths(): array { $paths = []; foreach (self::REASONS as $parent => $children) { foreach ($children as $child) { $paths[] = $parent . '/' . $child; } } return $paths; } /** @return array{code:string,last_succeeded_at:?string} */ public function syncStatus(): array { try { $schema = Schema::connection($this->connection->getName()); if (! $schema->hasTable('business_mirror_table_states') || ! $schema->hasTable($this->dataset()->targetTable())) { return ['code' => 'unavailable', 'last_succeeded_at' => null]; } $state = $this->mirror->state($this->dataset()); } catch (Throwable) { return ['code' => 'unavailable', 'last_succeeded_at' => null]; } if (! is_array($state)) { return ['code' => 'unavailable', 'last_succeeded_at' => null]; } $last = isset($state['last_succeeded_at']) ? (string) $state['last_succeeded_at'] : null; if (($state['initial_completed_at'] ?? null) === null) { return ['code' => ($state['status'] ?? '') === 'failed' ? 'initial_failed' : 'initializing', 'last_succeeded_at' => $last]; } return ['code' => ($state['status'] ?? '') === 'failed' ? 'failed' : 'ready', 'last_succeeded_at' => $last]; } /** * 当前接入口径下的全部记账工单(每个工单号只取最新一版),按发起时间倒序。 * 结果随镜像同步版本缓存;结果集为百至千级,页面筛选与分页在内存中完成。 * * @return list> */ public function rows(): array { $status = $this->syncStatus(); if (! in_array($status['code'], ['ready', 'failed'], true)) { throw new RuntimeException('business_mirror_dataset_not_ready'); } $key = 'supplier-bookkeeping:rows:v1:' . sha1((string) $status['last_succeeded_at']); $cached = Cache::get($key); if (is_array($cached)) { return $cached; } $rows = $this->load(); Cache::put($key, $rows, self::ROWS_CACHE_SECONDS); return $rows; } /** @return list> */ private function load(): array { $table = $this->dataset()->targetTable(); $query = $this->connection->table($table . ' AS w') ->leftJoin($table . ' AS newer', static function ($join): void { $join->on('newer.woTypeId', '=', 'w.woTypeId') ->on('newer.woNo', '=', 'w.woNo') ->on('newer.Id', '>', 'w.Id'); }) ->where('w.woTypeId', self::WORK_TYPE_ID) ->where('w.created', '>=', self::START_AT) // 镜像状态通常为英文码,兼容个别以中文状态落库的记录。 ->whereIn('w.status', [...array_keys(self::STATUS_LABELS), ...array_values(self::STATUS_LABELS)]) ->whereNull('newer.Id') // 粗筛只为减少传输:JSON 可能是原文或 \u 转义,两种写法都保留;精确判定在下方解析后完成。 ->where(function (Builder $reason): void { foreach (array_keys(self::REASONS) as $parent) { foreach ([$parent, trim(json_encode($parent), '"')] as $needle) { $reason->orWhereRaw("w.businessFieldList LIKE ? ESCAPE '!'", [$this->contains($needle)]); } } }) ->select([ 'w.Id', 'w.woNo', 'w.status', 'w.createby', 'w.executors', 'w.created', 'w.updated', 'w.businessFieldList', ]) ->orderByDesc('w.created') ->orderByDesc('w.woNo'); $allowed = array_flip(self::reasonPaths()); $rows = []; foreach ($this->boundedGet($query) as $record) { $fields = $this->fields((string) ($record->businessFieldList ?? '')); $reason = $this->reasonPath($fields[self::FIELD_REASON] ?? null); if (! isset($allowed[$reason])) { continue; } $rows[] = $this->decorate((array) $record, $fields, $reason); } return $rows; } /** * @param array $record * @param array $fields * @return array */ private function decorate(array $record, array $fields, string $reason): array { $levels = explode('/', $reason, 2); $row = [ 'source_id' => $record['Id'], 'started_at' => (string) $record['created'], 'work_order_no' => (string) $record['woNo'], 'initiator' => (string) ($record['createby'] ?? ''), 'bookkeeping_reason' => $reason, 'reason_level_1' => $levels[0], 'reason_level_2' => $levels[1] ?? '', 'executor' => (string) ($record['executors'] ?? ''), 'status' => self::STATUS_LABELS[(string) $record['status']] ?? (string) $record['status'], 'updated_at' => (string) ($record['updated'] ?? ''), 'work_order_type' => '记账', ]; foreach (self::HEADER_FIELDS as $key => $fieldId) { $row[$key] = $this->display($fields[$fieldId] ?? null); } $row['attachment_count'] = count($this->listValue($fields['attachmentField']['value'] ?? null)); $row['attachment'] = implode("\n", $this->listValue($fields['attachmentField']['value'] ?? null)); $row['amount'] = $this->money($row['amount']); $row['actual_paid_amount'] = $this->money($row['actual_paid_amount']); $items = $this->items($fields['itemInfo']['value'] ?? null, $fields['itemInfo']['raw'] ?? null); foreach (self::ITEM_FIELDS as $key => $candidates) { $values = array_map(fn (array $item): string => $this->firstOf($item, $candidates), $items); $row[$key] = implode("\n", array_filter($values, static fn (string $v): bool => $v !== '')); } if ($row['supplier'] === '') { $row['supplier'] = $row['header_supplier']; } unset($row['header_supplier']); return $row; } /** @return array}> fieldId => 原值 */ private function fields(string $json): array { if (trim($json) === '') { return []; } try { $decoded = json_decode($json, true, 64, JSON_THROW_ON_ERROR); } catch (Throwable) { return []; } if (! is_array($decoded)) { return []; } // 兼容 {"businessField":[...]} 包裹形式。 if (isset($decoded['businessField']) && is_array($decoded['businessField'])) { $decoded = $decoded['businessField']; } $fields = []; foreach ($decoded as $item) { if (! is_array($item) || ! isset($item['fieldId'])) { continue; } $fields[(string) $item['fieldId']] = [ 'value' => $item['value'] ?? null, 'label' => $item['valueLabel'] ?? null, 'raw' => $item, ]; } return $fields; } private function reasonPath(?array $field): string { if ($field === null) { return ''; } $value = $field['label'] ?? null; if ($value === null || $value === '' || $value === []) { $value = $field['value']; } if (is_array($value)) { $value = implode('/', array_map(static fn (mixed $part): string => trim((string) (is_array($part) ? implode('/', $part) : $part)), $value)); } return (string) preg_replace('/\s*\/\s*/u', '/', trim((string) $value)); } /** @param array{value:mixed,label:mixed}|null $field */ private function display(?array $field): string { if ($field === null) { return ''; } foreach ([$field['label'], $field['value']] as $value) { if (is_array($value)) { $value = implode('、', array_filter(array_map(static fn (mixed $v): string => is_scalar($v) ? trim((string) $v) : '', $value))); } if (is_scalar($value) && trim((string) $value) !== '') { return trim((string) $value); } } return ''; } /** @return list */ private function listValue(mixed $value): array { if (is_string($value) && str_starts_with(trim($value), '[')) { $value = json_decode($value, true); } if (is_string($value) && trim($value) !== '') { return [trim($value)]; } return is_array($value) ? array_values(array_filter(array_map(static fn (mixed $v): string => is_scalar($v) ? trim((string) $v) : '', $value))) : []; } /** * 商品明细:聚工单 API 为 value=[{skuId,...}],镜像中也可能是 childFieldList=[[{fieldId,value}...]]。 * * @param array|null $raw * @return list> */ private function items(mixed $value, ?array $raw): array { $items = []; if (is_string($value) && str_starts_with(trim($value), '[')) { $value = json_decode($value, true); } foreach (is_array($value) ? $value : [] as $item) { if (is_array($item) && ! array_is_list($item)) { $items[] = array_map(static fn (mixed $v): string => is_scalar($v) ? trim((string) $v) : '', $item); } } foreach ((array) ($raw['childFieldList'] ?? []) as $childRow) { $item = []; foreach ((array) $childRow as $child) { if (is_array($child) && isset($child['fieldId'])) { $v = $child['valueLabel'] ?? $child['value'] ?? ''; $item[(string) $child['fieldId']] = is_scalar($v) ? trim((string) $v) : ''; } } if ($item !== []) { $items[] = $item; } } return $items; } /** @param array $item @param list $candidates */ private function firstOf(array $item, array $candidates): string { foreach ($candidates as $key) { if (($item[$key] ?? '') !== '') { return $item[$key]; } } return ''; } private function money(string $value): ?string { $normalized = str_replace([',', ',', '¥', '¥', '元', ' '], '', trim($value)); if (! preg_match('/^-?\d+(\.\d+)?$/', $normalized)) { return null; } return number_format(round((float) $normalized, 2), 2, '.', ''); } /** @return iterable */ private function boundedGet(Builder $query): iterable { $connection = $query->getConnection(); if ($connection->getDriverName() !== 'mysql') { return $query->get(); } return $connection->select( 'SET STATEMENT max_statement_time=' . self::QUERY_TIMEOUT_SECONDS . ' FOR ' . $query->toSql(), $query->getBindings(), ); } private function contains(string $value): string { return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $value) . '%'; } private function dataset(): BusinessMirrorDataset { return $this->datasets->get(self::DATASET); } }