CaiWuXiTong/app/Business/PlantingWorkOrderSynchronizer.php
qiuxiang 8261b17328 种菜明细表同步:利润中心优先用历史值,缺失的用店铺维度报表的 ERP 店铺资料补齐
9 月有 19 家新店铺(1245 行)在历史映射里查不到,导致利润中心为空。现在按「ops_fys_shop_info.erpShopName → responsibleDepartment」补(就是店铺维度报表里 ERP系统店铺名称 → 店铺所属利润中心 那一列),两边都查不到才留空并在命令输出里列出。

历史值仍然优先,避免把人工维护过的取值改掉:实测 371 家可在 ERP 命中的店铺里 365 家两边一致,6 家不一致(朴美家居、朴美书房、拼多多-HX103、索菲亚办公、顾升、乐之益)已单独报给用户确认。
2026-10-08 09:59:11 +08:00

397 lines
16 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 App\Business;
use App\Models\Business\PlantingDetail;
use Illuminate\Database\DatabaseManager;
use InvalidArgumentException;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use RuntimeException;
/**
* 聚水潭「种菜报销」协同工单 → 种菜明细表(账单流水数据 › 种菜数据汇总 › 种菜明细表)月度同步。
*
* 口径(用 1~8 月已入库的 7 万多行反推、命中率 100%):
* - 只取「工单类型 = 种菜报销、工单状态 = 已完成」的工单;
* - 明细表「日期」= 工单完成日 − 1 天。所以某个月的数据 = 完成时间落在该月次日到下月次日的工单;
* - 一张工单一行,金额列与工单表单字段一一对应(见 FIELD_* 常量)。
*
* 排重:写入前先删掉「本月 + 本次订单号」的已有行,所以同一个月重复执行不会翻倍;
* 想整月覆盖(连没有对应工单的人工行一起清掉)用 --force-clear。
*
* 「所属利润中心」没有工单来源,按这个顺序取:
* 1. 表里已有的 店铺→利润中心 历史值(人工维护过的不覆盖);
* 2. 店铺维度报表的 ERP 系统店铺资料(ops_fys_shop_info.erpShopName → responsibleDepartment);
* 3. 两边都没有就留空,并在命令输出里列出来。
*/
class PlantingWorkOrderSynchronizer
{
/** 店铺维度报表用的店铺资料数据集(ERP系统店铺名称 → 店铺所属利润中心)。 */
public const SHOP_INFO_DATASET = 'operations_replica.rpt.fys_shopInfo';
/** 聚水潭协同工单镜像(jiansun.dbo.jst_workOrderInfo → js_jst_work_order_info)。 */
public const WORK_ORDER_DATASET = 'jiansun.dbo.jst_workOrderInfo';
/** 「种菜报销」这个工单类型的固定 id。 */
public const WORK_TYPE_ID = '6F03128403F2442CABCF283B59FAD88D';
/** 已完成的工单状态。 */
private const STATUS_CONFIRMED = 'CONFIRMED';
public const SOURCE = 'work-order-sync';
/** 工单表单字段(聚水潭只给 fieldId,不给字段名,这里的对应关系是拿历史数据逐列对撞出来的)。 */
private const FIELD_SELLING_PRICE = 'NUMBERField1413856620250924131615'; // 财务售价 = 任务付款金额
private const FIELD_FREIGHT = 'NUMBERField1413856620251113150537'; // 财务运费 = 任务快递费
private const FIELD_COMMISSION = 'NUMBERField1413856620251230154457'; // 财务佣金 = 任务佣金
private const FIELD_JINGXI_DIFF = 'NUMBERField1413856620250924131623'; // 京喜差价
private const FIELD_DEDUCTION = 'NUMBERField1413856620251113150612'; // 扣款 = 任务扣款费用
private const FIELD_EMPTY_PACKAGE = 'NUMBERField1413856620251113145620'; // 财务空包费 = 任务空包费
private const FIELD_PAYMENT = 'NUMBERField1413856620251117101250'; // 财务打款金额 = 最终报销金额
private const FIELD_OWNER = 'TEXTField1413856620251117180931'; // 负责人(收款人姓名)
private const FIELD_ACCOUNT = 'TEXTField1413856620251117113451'; // 账户(打款账号)
private const FIELD_TYPE = 'TEXTField1413856620251113150546'; // 类型(任务备注)
private const FIELD_TAG = 'businessRemarkField'; // 标签(业务备注)
private const FIELD_ORDER_NO = 'orderField'; // 订单编号(线上订单号)
private const FIELD_STORE = 'shopField'; // 正确店铺(店铺名称)
/** 金额列,统一按 4 位小数落库。 */
private const MONEY_COLUMNS = [
'finance_selling_price',
'finance_freight',
'finance_commission',
'jingxi_price_diff',
'deduction',
'finance_empty_package_fee',
'finance_payment_amount',
];
private const INSERT_CHUNK = 1000;
private const DELETE_CHUNK = 500;
public function __construct(
private readonly DatabaseManager $databases,
private readonly BusinessMirrorDatasetRegistry $datasets,
) {}
/**
* @return array{
* from:string,to:string,orders:int,inserted:int,deleted:int,
* selling_price:string,payment_amount:string,date_min:?string,date_max:?string,
* profit_center_from_erp:int,unmatched_stores:array<string,int>,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<string> $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<string,string> 店铺 → 利润中心(同店铺多个取值时取出现次数最多的) */
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<string,string>
*/
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<string,mixed> 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<string,mixed> $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;
}
}