,dry_run:bool * } */ public function sync( string $from, string $to, int $companyId = 1, bool $dryRun = false, bool $forceClear = false, ): array { $from = $this->normalizeDate($from, '起始日期'); $to = $this->normalizeDate($to, '结束日期'); if ($from > $to) { throw new InvalidArgumentException('起始日期不能晚于结束日期。'); } // 「日期」= 工单完成日 − 1,所以完成时间窗口整体后移一天。 $completeFrom = date('Y-m-d', strtotime($from . ' +1 day')) . ' 00:00:00'; $completeTo = date('Y-m-d', strtotime($to . ' +1 day')) . ' 00:00:00'; $target = $this->databases->connection(); $mirror = $this->databases->connection((string) config('upstream_mirrors.connection', 'business_mirror')); $table = $this->datasets->get(self::WORK_ORDER_DATASET)->targetTable(); if (! $mirror->getSchemaBuilder()->hasTable($table)) { throw new RuntimeException('聚水潭协同工单镜像尚未建立(' . $table . ')。'); } // 历史值优先(人工维护过的不覆盖),没有的再用 ERP 店铺资料补齐。 $storeToProfitCenter = $this->storeToProfitCenter($companyId); $erpProfitCenters = $this->erpStoreToProfitCenter($mirror); $now = now(); $rows = []; $orders = 0; $fromErp = 0; $unmatchedStores = []; $sellingPrice = 0.0; $paymentAmount = 0.0; $dateMin = null; $dateMax = null; $records = $mirror->table($table) ->where('woTypeId', self::WORK_TYPE_ID) ->where('status', self::STATUS_CONFIRMED) ->where('completeTime', '>=', $completeFrom) ->where('completeTime', '<', $completeTo) ->orderBy('completeTime') ->orderBy('woNo') ->get(['woNo', 'createby', 'completeTime', 'businessFieldList']); foreach ($records as $record) { $fields = $this->fields((string) $record->businessFieldList); $orderNo = $this->text($fields[self::FIELD_ORDER_NO] ?? null); if ($orderNo === null) { continue; } $billDate = date('Y-m-d', strtotime((string) $record->completeTime . ' -1 day')); if ($billDate < $from || $billDate > $to) { continue; } $store = $this->text($fields[self::FIELD_STORE . '#label'] ?? null); $profitCenter = null; if ($store !== null) { $profitCenter = $storeToProfitCenter[$store] ?? null; if ($profitCenter === null && isset($erpProfitCenters[$store])) { $profitCenter = $erpProfitCenters[$store]; $fromErp++; } } if ($store !== null && $profitCenter === null) { $unmatchedStores[$store] = ($unmatchedStores[$store] ?? 0) + 1; } $row = [ 'company_id' => $companyId, 'bill_date' => $billDate, 'order_no' => mb_substr($orderNo, 0, 100), 'pieces' => 1, 'remark' => null, 'store' => $store, 'owner' => $this->text($fields[self::FIELD_OWNER] ?? null), 'account' => $this->text($fields[self::FIELD_ACCOUNT] ?? null), 'type' => $this->text($fields[self::FIELD_TYPE] ?? null), 'creator' => $this->text($record->createby), 'tag' => $this->text($fields[self::FIELD_TAG] ?? null), 'profit_center' => $profitCenter, 'responsible_platform' => null, 'external_id' => $this->text($this->platformItemId($fields), 100), 'created_from' => self::SOURCE, 'created_by' => null, 'created_at' => $now, 'updated_at' => $now, ]; foreach (self::MONEY_COLUMNS as $column) { $row[$column] = number_format(0.0, 4, '.', ''); } foreach ([ 'finance_selling_price' => self::FIELD_SELLING_PRICE, 'finance_freight' => self::FIELD_FREIGHT, 'finance_commission' => self::FIELD_COMMISSION, 'jingxi_price_diff' => self::FIELD_JINGXI_DIFF, 'deduction' => self::FIELD_DEDUCTION, 'finance_empty_package_fee' => self::FIELD_EMPTY_PACKAGE, 'finance_payment_amount' => self::FIELD_PAYMENT, ] as $column => $field) { $row[$column] = number_format($this->number($fields[$field] ?? null), 4, '.', ''); } $orders++; $sellingPrice += (float) $row['finance_selling_price']; $paymentAmount += (float) $row['finance_payment_amount']; $dateMin = $dateMin === null || $billDate < $dateMin ? $billDate : $dateMin; $dateMax = $dateMax === null || $billDate > $dateMax ? $billDate : $dateMax; $rows[] = $row; } $orderNumbers = array_values(array_unique(array_column($rows, 'order_no'))); $deleted = 0; if (! $dryRun) { $deleted = $this->clear($target, $companyId, $from, $to, $orderNumbers, $forceClear); foreach (array_chunk($rows, self::INSERT_CHUNK) as $chunk) { $target->table('planting_details')->insert($chunk); } } arsort($unmatchedStores); return [ 'from' => $from, 'to' => $to, 'orders' => $orders, 'inserted' => $dryRun ? 0 : count($rows), 'deleted' => $deleted, 'selling_price' => number_format($sellingPrice, 4, '.', ''), 'payment_amount' => number_format($paymentAmount, 4, '.', ''), 'date_min' => $dateMin, 'date_max' => $dateMax, 'profit_center_from_erp' => $fromErp, 'unmatched_stores' => $unmatchedStores, 'dry_run' => $dryRun, ]; } /** * 排重:删掉本月内、这次要写入的那批订单号对应的已有行。 * * @param list $orderNumbers */ private function clear( \Illuminate\Database\ConnectionInterface $target, int $companyId, string $from, string $to, array $orderNumbers, bool $forceClear, ): int { $base = static fn () => $target->table('planting_details') ->where('company_id', $companyId) ->where('bill_date', '>=', $from) ->where('bill_date', '<=', $to); if ($forceClear) { return (int) $base()->delete(); } $deleted = 0; foreach (array_chunk($orderNumbers, self::DELETE_CHUNK) as $chunk) { $deleted += (int) $base()->whereIn('order_no', $chunk)->delete(); } return $deleted; } /** @return array 店铺 → 利润中心(同店铺多个取值时取出现次数最多的) */ private function storeToProfitCenter(int $companyId): array { $counts = []; $rows = PlantingDetail::query() ->where('company_id', $companyId) ->whereNotNull('store') ->whereNotNull('profit_center') ->selectRaw('store, profit_center, COUNT(*) AS total') ->groupBy('store', 'profit_center') ->get(); foreach ($rows as $row) { $store = (string) $row->store; $counts[$store][(string) $row->profit_center] = (int) $row->total; } $map = []; foreach ($counts as $store => $values) { arsort($values); $map[$store] = (string) array_key_first($values); } return $map; } /** * 店铺维度报表的 ERP 系统店铺 → 店铺所属利润中心(ops_fys_shop_info.responsibleDepartment)。 * * @return array */ private function erpStoreToProfitCenter(\Illuminate\Database\ConnectionInterface $mirror): array { $table = $this->datasets->get(self::SHOP_INFO_DATASET)->targetTable(); if (! $mirror->getSchemaBuilder()->hasTable($table)) { return []; } $map = []; foreach ($mirror->table($table) ->whereNotNull('erpShopName') ->where('responsibleDepartment', '<>', '') ->get(['erpShopName', 'responsibleDepartment']) as $row) { $name = trim((string) $row->erpShopName); $department = trim((string) $row->responsibleDepartment); if ($name !== '' && $department !== '' && ! isset($map[$name])) { $map[$name] = mb_substr($department, 0, 191); } } return $map; } /** @return array fieldId → value(带 valueLabel 的另存为 fieldId#label) */ private function fields(string $businessFieldList): array { $fields = []; $items = json_decode($businessFieldList, true); if (! is_array($items)) { return $fields; } foreach ($items as $item) { if (! is_array($item) || ! isset($item['fieldId'])) { continue; } if ($item['fieldId'] === 'itemInfo') { // 商品明细是嵌套结构,原样留着给 platformItemId 取平台商品 ID。 $fields['itemInfo'] = $item; continue; } $value = $item['value'] ?? null; if (is_array($value)) { $value = implode(',', array_map('strval', $value)); } $fields[(string) $item['fieldId']] = $value; if (($item['valueLabel'] ?? null) !== null) { $fields[(string) $item['fieldId'] . '#label'] = $item['valueLabel']; } } return $fields; } /** @param array $fields */ private function platformItemId(array $fields): ?string { $itemInfo = $fields['itemInfo'] ?? null; if (! is_array($itemInfo)) { return null; } foreach ((array) ($itemInfo['childFieldList'] ?? []) as $childRow) { foreach ((array) $childRow as $child) { if (is_array($child) && ($child['fieldId'] ?? null) === 'platformItemId') { return $this->text($child['value'] ?? null, 100); } } } return null; } private function text(mixed $value, int $limit = 191): ?string { if ($value === null) { return null; } $text = trim((string) (is_scalar($value) ? $value : '')); return $text === '' ? null : mb_substr($text, 0, $limit); } private function number(mixed $value): float { if ($value === null || $value === '') { return 0.0; } return is_numeric($value) ? (float) $value : 0.0; } private function normalizeDate(string $value, string $label): string { $value = trim($value); if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) !== 1) { throw new InvalidArgumentException($label . '必须是 YYYY-MM-DD。'); } [$year, $month, $day] = array_map('intval', explode('-', $value)); if (! checkdate($month, $day, $year)) { throw new InvalidArgumentException($label . '不是有效日期。'); } return $value; } }