9 月有 19 家新店铺(1245 行)在历史映射里查不到,导致利润中心为空。现在按「ops_fys_shop_info.erpShopName → responsibleDepartment」补(就是店铺维度报表里 ERP系统店铺名称 → 店铺所属利润中心 那一列),两边都查不到才留空并在命令输出里列出。 历史值仍然优先,避免把人工维护过的取值改掉:实测 371 家可在 ERP 命中的店铺里 365 家两边一致,6 家不一致(朴美家居、朴美书房、拼多多-HX103、索菲亚办公、顾升、乐之益)已单独报给用户确认。
397 lines
16 KiB
PHP
397 lines
16 KiB
PHP
<?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;
|
||
}
|
||
}
|