diff --git a/app/Business/CourierPayableSummaryFilter.php b/app/Business/CourierPayableSummaryFilter.php new file mode 100644 index 00000000..8a5f2144 --- /dev/null +++ b/app/Business/CourierPayableSummaryFilter.php @@ -0,0 +1,120 @@ +query($key, ''); + + if (! is_scalar($value)) { + return ''; + } + + return mb_substr(trim((string) $value), 0, $maxLength); + }; + + return [ + 'date' => $clean('date', 50), + 'person' => $clean('person', 100), + 'courier' => $clean('courier', 100), + 'keyword' => $clean('keyword', 191), + ]; + } + + /** @param array{date:string,person:string,courier:string,keyword:string} $filters */ + public function hasFilters(array $filters): bool + { + return collect($filters)->contains(static fn (string $value): bool => $value !== ''); + } + + /** + * @param Collection $rows + * @param list $headers + * @param array{date:string,person:string,courier:string,keyword:string} $filters + * @return Collection + */ + public function apply(Collection $rows, array $headers, array $filters): Collection + { + if (! $this->hasFilters($filters)) { + return $rows; + } + + $dateColumn = $this->columnIndex($headers, ['日期', '结算月份', '结算月', '月份']); + $personColumn = $this->columnIndex($headers, ['负责人', '责任人']); + $courierColumn = $this->columnIndex($headers, ['快递公司', '快递名称', '快递名', '快递']); + + return $rows->filter(function (mixed $row) use ($filters, $dateColumn, $personColumn, $courierColumn): bool { + if (! is_array($row)) { + return false; + } + + if (! $this->columnMatches($row, $dateColumn, $filters['date']) + || ! $this->columnMatches($row, $personColumn, $filters['person']) + || ! $this->columnMatches($row, $courierColumn, $filters['courier'])) { + return false; + } + + if ($filters['keyword'] === '') { + return true; + } + + foreach ($row as $value) { + if ($this->contains($value, $filters['keyword'])) { + return true; + } + } + + return false; + }); + } + + /** @param list $headers */ + public function dateColumnIndex(array $headers): ?int + { + return $this->columnIndex($headers, ['日期', '结算月份', '结算月', '月份']); + } + + /** @param list $headers */ + private function columnIndex(array $headers, array $aliases): ?int + { + $normalizedAliases = array_map( + static fn (string $alias): string => mb_strtolower(preg_replace('/[\\s ]+/u', '', $alias) ?? $alias), + $aliases, + ); + + foreach ($headers as $index => $header) { + $normalizedHeader = mb_strtolower(preg_replace('/[\\s ]+/u', '', trim((string) $header)) ?? trim((string) $header)); + + if (in_array($normalizedHeader, $normalizedAliases, true)) { + return (int) $index; + } + } + + return null; + } + + private function columnMatches(array $row, ?int $column, string $needle): bool + { + if ($needle === '') { + return true; + } + + return $column !== null && $this->contains($row[$column] ?? '', $needle); + } + + private function contains(mixed $value, string $needle): bool + { + if (! is_scalar($value) && ! $value instanceof \Stringable) { + return false; + } + + return mb_stripos((string) $value, $needle, 0, 'UTF-8') !== false; + } +} diff --git a/app/Business/CourierPayableSummaryStore.php b/app/Business/CourierPayableSummaryStore.php index ffe12e9e..c7f7aaba 100644 --- a/app/Business/CourierPayableSummaryStore.php +++ b/app/Business/CourierPayableSummaryStore.php @@ -16,11 +16,16 @@ class CourierPayableSummaryStore public const PAGES = [ 'freight-difference' => '运费差', 'other-surcharge-summary' => '其他附加费汇总', + 'return-fee' => '退件费', ]; private const MAX_COLUMNS = 200; - private const MAX_ROWS = 10000; + private const MAX_ROWS_BY_PAGE = [ + 'freight-difference' => 500_000, + 'other-surcharge-summary' => 500_000, + 'return-fee' => 500_000, + ]; private const MAX_CELL_LENGTH = 10000; @@ -31,6 +36,13 @@ class CourierPayableSummaryStore return self::PAGES[$page]; } + public function maxRows(string $page): int + { + $this->title($page); + + return self::MAX_ROWS_BY_PAGE[$page]; + } + /** * @return array{version:int,headers:list,rows:list>,source_name:string,imported_at:string,imported_by:int|null}|null */ @@ -72,7 +84,7 @@ class CourierPayableSummaryStore $reader = IOFactory::createReaderForFile($file->getRealPath()); $reader->setReadDataOnly(true); $spreadsheet = $reader->load($file->getRealPath()); - [$headers, $rows] = $this->readSpreadsheet($spreadsheet); + [$headers, $rows] = $this->readSpreadsheet($spreadsheet, $page); } catch (ValidationException $exception) { throw $exception; } catch (Throwable $exception) { @@ -128,8 +140,10 @@ class CourierPayableSummaryStore } /** @return array{0:list,1:list>} */ - private function readSpreadsheet($spreadsheet): array + private function readSpreadsheet($spreadsheet, string $page): array { + $maxRows = $this->maxRows($page); + foreach ($spreadsheet->getWorksheetIterator() as $worksheet) { $rawRows = $worksheet->toArray(null, false, true, false); $headerIndex = $this->headerIndex($rawRows); @@ -169,9 +183,9 @@ class CourierPayableSummaryStore $rows[] = $row; - if (count($rows) > self::MAX_ROWS) { + if (count($rows) > $maxRows) { throw ValidationException::withMessages([ - 'import' => '单次最多导入 ' . number_format(self::MAX_ROWS) . ' 条汇总数据,请拆分文件后重试。', + 'import' => '单次最多导入 ' . number_format($maxRows) . ' 条汇总数据,请拆分文件后重试。', ]); } } diff --git a/app/Business/CourierSurchargeAuditLogRepository.php b/app/Business/CourierSurchargeAuditLogRepository.php new file mode 100644 index 00000000..f6140b4b --- /dev/null +++ b/app/Business/CourierSurchargeAuditLogRepository.php @@ -0,0 +1,148 @@ + */ + private const FIELD_LABELS = [ + 'courier' => '快递名称', + 'scope' => '快递归属', + 'reconciler' => '对账人', + 'types' => '附加费类型', + 'mode' => '结算方式', + 'standard' => '计价标准', + 'condition' => '生效条件', + 'effective_at' => '生效日期', + 'remark' => '备注', + 'status' => '状态', + 'name' => '类型名称', + ]; + + /** @var array */ + private const ACTION_LABELS = [ + CourierSurchargeAuditLog::ACTION_CREATED => '新增', + CourierSurchargeAuditLog::ACTION_UPDATED => '更改', + CourierSurchargeAuditLog::ACTION_ACTIVATED => '启用', + CourierSurchargeAuditLog::ACTION_DEACTIVATED => '停用', + ]; + + /** @var array */ + private const TARGET_LABELS = [ + CourierSurchargeAuditLog::TARGET_QUOTE => '报价规则', + CourierSurchargeAuditLog::TARGET_TYPE => '附加费类型', + ]; + + public function ready(): bool + { + try { + return Schema::hasTable((new CourierSurchargeAuditLog())->getTable()); + } catch (QueryException) { + return false; + } + } + + /** @param array $changes */ + public function record( + string $action, + string $targetType, + ?int $targetId, + string $targetLabel, + array $changes, + int $actorId, + ): CourierSurchargeAuditLog { + abort_unless($this->ready(), 503, '附加费操作日志数据表尚未完成迁移。'); + abort_unless(array_key_exists($action, self::ACTION_LABELS), 422, '附加费日志动作不合法。'); + abort_unless(array_key_exists($targetType, self::TARGET_LABELS), 422, '附加费日志对象不合法。'); + + return CourierSurchargeAuditLog::query()->create([ + 'company_id' => company_id(), + 'actor_id' => $actorId ?: null, + 'action' => $action, + 'target_type' => $targetType, + 'target_id' => $targetId, + 'target_label' => mb_substr(trim($targetLabel), 0, 191), + 'changes' => $changes, + ]); + } + + /** + * @return Collection> + */ + public function recent(int $limit = 50): Collection + { + if (! $this->ready()) { + return collect(); + } + + try { + return CourierSurchargeAuditLog::query() + ->with('actor') + ->orderByDesc('id') + ->limit(max(1, min($limit, 200))) + ->get() + ->map(fn (CourierSurchargeAuditLog $log): array => [ + 'id' => $log->id, + 'occurred_at' => optional($log->created_at)->format('Y-m-d H:i:s') ?: '', + 'actor_name' => $log->actor?->name ?: '系统', + 'action' => $log->action, + 'action_label' => self::ACTION_LABELS[$log->action] ?? $log->action, + 'target_type_label' => self::TARGET_LABELS[$log->target_type] ?? $log->target_type, + 'target_label' => $log->target_label, + 'changes_label' => $this->changesLabel($log), + ]) + ->values(); + } catch (QueryException) { + return collect(); + } + } + + private function changesLabel(CourierSurchargeAuditLog $log): string + { + $changes = (array) $log->changes; + if ($log->action === CourierSurchargeAuditLog::ACTION_CREATED) { + return '新增记录'; + } + + if (in_array($log->action, [CourierSurchargeAuditLog::ACTION_ACTIVATED, CourierSurchargeAuditLog::ACTION_DEACTIVATED], true)) { + $before = data_get($changes, 'before.status'); + $after = data_get($changes, 'after.status'); + return sprintf('状态:%s → %s', $before ?: '—', $after ?: '—'); + } + + $before = (array) ($changes['before'] ?? []); + $after = (array) ($changes['after'] ?? []); + $parts = []; + foreach (array_unique(array_merge(array_keys($before), array_keys($after))) as $field) { + $old = $this->valueLabel($before[$field] ?? null); + $new = $this->valueLabel($after[$field] ?? null); + if ($old !== $new) { + $parts[] = sprintf('%s:%s → %s', self::FIELD_LABELS[$field] ?? $field, $old, $new); + } + } + + return $parts === [] ? '未改变字段' : implode(';', $parts); + } + + private function valueLabel(mixed $value): string + { + if (is_array($value)) { + return implode('、', array_map(static fn (mixed $item): string => (string) $item, $value)); + } + + $value = trim((string) $value); + + return $value === '' ? '—' : $value; + } +} diff --git a/app/Business/CourierSurchargeQuoteRepository.php b/app/Business/CourierSurchargeQuoteRepository.php index 8cc35a10..e5512285 100644 --- a/app/Business/CourierSurchargeQuoteRepository.php +++ b/app/Business/CourierSurchargeQuoteRepository.php @@ -2,10 +2,12 @@ namespace App\Business; +use App\Models\Business\CourierSurchargeAuditLog; use App\Models\Business\CourierSurchargeQuote; use App\Models\Business\CourierSurchargeType; use Illuminate\Database\QueryException; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; /** @@ -28,7 +30,10 @@ class CourierSurchargeQuoteRepository public const SCOPES = ['自有快递', '代发仓']; - public function __construct(private readonly CourierSurchargeTypeRepository $types) + public function __construct( + private readonly CourierSurchargeTypeRepository $types, + private readonly CourierSurchargeAuditLogRepository $audits, + ) { } @@ -154,27 +159,102 @@ class CourierSurchargeQuoteRepository /** @param array $data */ public function create(array $data, int $userId): CourierSurchargeQuote { - return CourierSurchargeQuote::query()->create($this->attributes($data, $userId) + [ - 'status' => CourierSurchargeQuote::STATUS_ACTIVE, - 'created_by' => $userId, - ]); + return DB::transaction(function () use ($data, $userId): CourierSurchargeQuote { + $quote = CourierSurchargeQuote::query()->create($this->attributes($data, $userId) + [ + 'status' => CourierSurchargeQuote::STATUS_ACTIVE, + 'created_by' => $userId, + ]); + + $this->audits->record( + CourierSurchargeAuditLog::ACTION_CREATED, + CourierSurchargeAuditLog::TARGET_QUOTE, + (int) $quote->id, + $this->targetLabel($quote), + ['after' => $this->snapshot($quote)], + $userId, + ); + + return $quote; + }); } /** @param array $data */ public function update(CourierSurchargeQuote $quote, array $data, int $userId): CourierSurchargeQuote { - $quote->update($this->attributes($data, $userId)); + return DB::transaction(function () use ($quote, $data, $userId): CourierSurchargeQuote { + $before = $this->snapshot($quote); + $quote->update($this->attributes($data, $userId)); + $fresh = $quote->fresh(); - return $quote->fresh(); + $this->audits->record( + CourierSurchargeAuditLog::ACTION_UPDATED, + CourierSurchargeAuditLog::TARGET_QUOTE, + (int) $fresh->id, + $this->targetLabel($fresh), + ['before' => $before, 'after' => $this->snapshot($fresh)], + $userId, + ); + + return $fresh; + }); } public function setStatus(CourierSurchargeQuote $quote, string $status, int $userId): CourierSurchargeQuote { abort_unless(in_array($status, [CourierSurchargeQuote::STATUS_ACTIVE, CourierSurchargeQuote::STATUS_INACTIVE], true), 422, '报价规则状态不合法。'); - $quote->forceFill(['status' => $status, 'updated_by' => $userId])->save(); + return DB::transaction(function () use ($quote, $status, $userId): CourierSurchargeQuote { + $oldStatus = $quote->status; + $quote->forceFill(['status' => $status, 'updated_by' => $userId])->save(); + $fresh = $quote->fresh(); - return $quote->fresh(); + if ($oldStatus !== $status) { + $this->audits->record( + $status === CourierSurchargeQuote::STATUS_ACTIVE + ? CourierSurchargeAuditLog::ACTION_ACTIVATED + : CourierSurchargeAuditLog::ACTION_DEACTIVATED, + CourierSurchargeAuditLog::TARGET_QUOTE, + (int) $fresh->id, + $this->targetLabel($fresh), + [ + 'before' => ['status' => $this->statusLabel($oldStatus)], + 'after' => ['status' => $this->statusLabel($status)], + ], + $userId, + ); + } + + return $fresh; + }); + } + + private function targetLabel(CourierSurchargeQuote $quote): string + { + $type = $quote->typeLabel(); + + return trim(sprintf('%s%s', $quote->courier, $type === '' ? '' : ' · ' . $type)); + } + + /** @return array */ + private function snapshot(CourierSurchargeQuote $quote): array + { + return [ + 'courier' => $quote->courier, + 'scope' => $quote->scope, + 'reconciler' => $quote->reconciler, + 'types' => array_values((array) $quote->types), + 'mode' => $quote->mode, + 'standard' => $quote->standard, + 'condition' => $quote->condition, + 'effective_at' => optional($quote->effective_at)->format('Y-m-d'), + 'status' => $this->statusLabel($quote->status), + 'remark' => $quote->remark, + ]; + } + + private function statusLabel(?string $status): string + { + return $status === CourierSurchargeQuote::STATUS_ACTIVE ? '已启用' : '已停用'; } /** @param array $data */ diff --git a/app/Business/CourierSurchargeTypeRepository.php b/app/Business/CourierSurchargeTypeRepository.php index 674cdee2..66791f73 100644 --- a/app/Business/CourierSurchargeTypeRepository.php +++ b/app/Business/CourierSurchargeTypeRepository.php @@ -2,10 +2,12 @@ namespace App\Business; +use App\Models\Business\CourierSurchargeAuditLog; use App\Models\Business\CourierSurchargeQuote; use App\Models\Business\CourierSurchargeType; use Illuminate\Database\QueryException; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use Illuminate\Validation\ValidationException; @@ -17,6 +19,10 @@ use Illuminate\Validation\ValidationException; */ class CourierSurchargeTypeRepository { + public function __construct(private readonly CourierSurchargeAuditLogRepository $audits) + { + } + /** @var list */ public const DEFAULT_NAMES = [ '退回费', '拦截费', '改地址', '异形件', '超长费', @@ -89,14 +95,27 @@ class CourierSurchargeTypeRepository $sortOrder = ((int) CourierSurchargeType::query()->max('sort_order')) + 1; - return CourierSurchargeType::query()->create([ - 'company_id' => company_id(), - 'name' => $name, - 'status' => CourierSurchargeType::STATUS_ACTIVE, - 'sort_order' => $sortOrder, - 'created_by' => $userId, - 'updated_by' => $userId, - ]); + return DB::transaction(function () use ($name, $sortOrder, $userId): CourierSurchargeType { + $type = CourierSurchargeType::query()->create([ + 'company_id' => company_id(), + 'name' => $name, + 'status' => CourierSurchargeType::STATUS_ACTIVE, + 'sort_order' => $sortOrder, + 'created_by' => $userId, + 'updated_by' => $userId, + ]); + + $this->audits->record( + CourierSurchargeAuditLog::ACTION_CREATED, + CourierSurchargeAuditLog::TARGET_TYPE, + (int) $type->id, + $type->name, + ['after' => ['name' => $type->name, 'status' => '已启用']], + $userId, + ); + + return $type; + }); } catch (QueryException $exception) { if ((int) $exception->getCode() === 23000) { throw ValidationException::withMessages(['name' => '当前公司已有同名附加费类型。']); @@ -110,12 +129,37 @@ class CourierSurchargeTypeRepository { abort_unless(in_array($status, [CourierSurchargeType::STATUS_ACTIVE, CourierSurchargeType::STATUS_INACTIVE], true), 422, '附加费类型状态不合法。'); - $type->forceFill([ - 'status' => $status, - 'updated_by' => $userId, - ])->save(); + return DB::transaction(function () use ($type, $status, $userId): CourierSurchargeType { + $oldStatus = $type->status; + $type->forceFill([ + 'status' => $status, + 'updated_by' => $userId, + ])->save(); + $fresh = $type->fresh(); - return $type->fresh(); + if ($oldStatus !== $status) { + $this->audits->record( + $status === CourierSurchargeType::STATUS_ACTIVE + ? CourierSurchargeAuditLog::ACTION_ACTIVATED + : CourierSurchargeAuditLog::ACTION_DEACTIVATED, + CourierSurchargeAuditLog::TARGET_TYPE, + (int) $fresh->id, + $fresh->name, + [ + 'before' => ['status' => $this->statusLabel($oldStatus)], + 'after' => ['status' => $this->statusLabel($status)], + ], + $userId, + ); + } + + return $fresh; + }); + } + + private function statusLabel(?string $status): string + { + return $status === CourierSurchargeType::STATUS_ACTIVE ? '已启用' : '已停用'; } private function ensureDefaults(int $companyId): void diff --git a/app/Business/TaobaoAlipayBillSpreadsheetImporter.php b/app/Business/TaobaoAlipayBillSpreadsheetImporter.php index c6fad7a1..fb8af9e2 100644 --- a/app/Business/TaobaoAlipayBillSpreadsheetImporter.php +++ b/app/Business/TaobaoAlipayBillSpreadsheetImporter.php @@ -35,7 +35,7 @@ class TaobaoAlipayBillSpreadsheetImporter 'counterparty_account', 'account_order_no', 'business_serial_no', 'goods_name', ]; - public function import(string $path, int $companyId): array + public function import(string $path, int $companyId, bool $replace = false): array { if (! is_file($path) || ! is_readable($path)) { throw new InvalidArgumentException('导入文件不存在或不可读取。'); @@ -55,21 +55,27 @@ class TaobaoAlipayBillSpreadsheetImporter throw new InvalidArgumentException('表头与淘系支付宝账户模板不一致。'); } + $rows = []; + $skipped = 0; + for ($rowNumber = 2; $rowNumber <= $sheet->getHighestDataRow(); $rowNumber++) { + $values = $sheet->rangeToArray("A{$rowNumber}:Y{$rowNumber}", null, false, false)[0]; + if (collect($values)->every(fn ($value) => $value === null || trim((string) $value) === '')) { + $skipped++; + continue; + } + $rows[] = $this->normalizeRow(array_combine(self::FIELDS, $values), $rowNumber); + } + $inserted = 0; $updated = 0; - $skipped = 0; + $replaced = 0; - DB::transaction(function () use ($sheet, $companyId, &$inserted, &$updated, &$skipped) { - for ($rowNumber = 2; $rowNumber <= $sheet->getHighestDataRow(); $rowNumber++) { - $values = $sheet->rangeToArray("A{$rowNumber}:Y{$rowNumber}", null, false, false)[0]; + DB::transaction(function () use ($rows, $companyId, $replace, &$inserted, &$updated, &$replaced) { + if ($replace) { + $replaced = DB::table('ec_alipay_bills')->where('company_id', $companyId)->delete(); + } - if (collect($values)->every(fn ($value) => $value === null || trim((string) $value) === '')) { - $skipped++; - continue; - } - - $data = array_combine(self::FIELDS, $values); - $data = $this->normalizeRow($data, $rowNumber); + foreach ($rows as $data) { $record = AlipayBill::query() ->where('company_id', $companyId) ->where('record_code', $data['record_code']) @@ -93,7 +99,7 @@ class TaobaoAlipayBillSpreadsheetImporter $spreadsheet->disconnectWorksheets(); - return compact('inserted', 'updated', 'skipped'); + return compact('inserted', 'updated', 'skipped', 'replaced'); } private function normalizeRow(array $data, int $rowNumber): array diff --git a/app/Console/Commands/ImportInventoryReportAuthoritativeMonth.php b/app/Console/Commands/ImportInventoryReportAuthoritativeMonth.php new file mode 100644 index 00000000..46d75528 --- /dev/null +++ b/app/Console/Commands/ImportInventoryReportAuthoritativeMonth.php @@ -0,0 +1,36 @@ +import((string) $this->argument('path')); + } catch (Throwable $exception) { + report($exception); + $this->error('进销存权威月报导入失败:' . $exception->getMessage()); + $this->warn('已保留上一版有效数据。'); + + return self::FAILURE; + } + + $this->info(sprintf( + '进销存权威月报导入完成:月份 %s,明细 %d 行。', + $result['source_month'], + $result['source_rows'], + )); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/ImportTaobaoAlipayBills.php b/app/Console/Commands/ImportTaobaoAlipayBills.php index 57dae215..3e4b228e 100644 --- a/app/Console/Commands/ImportTaobaoAlipayBills.php +++ b/app/Console/Commands/ImportTaobaoAlipayBills.php @@ -10,7 +10,8 @@ class ImportTaobaoAlipayBills extends Command { protected $signature = 'business:import-taobao-alipay-bills {file : 淘系支付宝账户 Excel 文件路径} - {--company=1 : 数据所属公司 ID}'; + {--company=1 : 数据所属公司 ID} + {--replace : 校验通过后替换该公司的全部淘系支付宝账单}'; protected $description = '从标准附件导入店铺收款账户账单的淘系支付宝账户数据'; @@ -20,6 +21,7 @@ class ImportTaobaoAlipayBills extends Command $result = $importer->import( (string) $this->argument('file'), max(1, (int) $this->option('company')), + (bool) $this->option('replace'), ); } catch (Throwable $e) { $this->components->error($e->getMessage()); @@ -28,7 +30,7 @@ class ImportTaobaoAlipayBills extends Command } $this->components->info( - "导入完成:新增 {$result['inserted']} 条,更新 {$result['updated']} 条,跳过 {$result['skipped']} 条。" + "导入完成:新增 {$result['inserted']} 条,更新 {$result['updated']} 条,替换旧数据 {$result['replaced']} 条,跳过 {$result['skipped']} 条。" ); return self::SUCCESS; diff --git a/app/Console/Commands/SyncThirdPartyWarehouseStorage.php b/app/Console/Commands/SyncThirdPartyWarehouseStorage.php index 7cb1d85c..a7f937a0 100644 --- a/app/Console/Commands/SyncThirdPartyWarehouseStorage.php +++ b/app/Console/Commands/SyncThirdPartyWarehouseStorage.php @@ -10,10 +10,10 @@ use Throwable; class SyncThirdPartyWarehouseStorage extends Command { protected $signature = 'business-mirror:sync-third-party-warehouse-storage - {--full : 启动或继续三方仓流水与库存的全量校准} + {--full : 启动或继续聚水潭全仓流水与库存的全量校准} {--max-batches= : 本轮最多批次数,0 表示直到完成}'; - protected $description = '同步南昌樵舍三方仓按商品进销存数据到共享业务镜像'; + protected $description = '同步聚水潭全部仓库按商品进销存数据到共享业务镜像'; public function handle(ThirdPartyWarehouseStorageSynchronizer $synchronizer): int { @@ -27,29 +27,29 @@ class SyncThirdPartyWarehouseStorage extends Command $this->option('max-batches') === null ? null : (int) $this->option('max-batches'), ); } catch (Throwable $exception) { - Log::error('三方仓仓储数据共享镜像同步失败。', [ + Log::error('聚水潭进销存数据共享镜像同步失败。', [ 'dataset' => ThirdPartyWarehouseStorageSynchronizer::DATASET_KEY, 'exception' => $exception::class, ]); - $this->error('三方仓仓储数据同步失败,页面继续读取上一版完整数据。'); + $this->error('聚水潭进销存数据同步失败,页面继续读取上一版完整数据。'); return self::FAILURE; } if ($result['status'] === 'requires_full_sync') { - $this->warn('三方仓仓储数据尚未完成首次全量同步,请显式使用 --full。'); + $this->warn('聚水潭进销存数据尚未完成首次全量同步,请显式使用 --full。'); return self::SUCCESS; } if ($result['status'] === 'already_running') { - $this->line('三方仓仓储数据已有同步任务运行,本次不重复启动。'); + $this->line('聚水潭进销存数据已有同步任务运行,本次不重复启动。'); return self::SUCCESS; } $this->info(sprintf( - '三方仓仓储数据%s:%s,本轮 %d 批;流水 %d 条、明细 %d 条、库存 %d 条。', + '聚水潭进销存数据%s:%s,本轮 %d 批;流水 %d 条、明细 %d 条、库存 %d 条。', $result['mode'] === 'full' ? '全量校准' : '增量同步', $result['status'] === 'succeeded' ? '完成' : '进行中', $result['batches'], diff --git a/app/Http/Controllers/Business/CourierAgreementSignatures.php b/app/Http/Controllers/Business/CourierAgreementSignatures.php index 0cef1868..f62a789e 100644 --- a/app/Http/Controllers/Business/CourierAgreementSignatures.php +++ b/app/Http/Controllers/Business/CourierAgreementSignatures.php @@ -278,6 +278,7 @@ class CourierAgreementSignatures extends Controller 'quote_version_id' => ['required_if:document_type,quote-confirmation', 'nullable', 'integer', 'min:1'], 'signing_subject' => ['required', 'string', 'min:2', 'max:255'], 'company_full_name' => ['required', 'string', 'min:2', 'max:255'], + 'signature_date' => ['required', 'date'], 'signing_deadline' => $date, 'esign_account' => ['required', 'string', 'max:255'], 'secondary_esign_account' => ['required_if:document_type,entity-change,cooperation-contract,authorization', 'nullable', 'string', 'max:255'], @@ -299,12 +300,10 @@ class CourierAgreementSignatures extends Controller 'entity_change_date' => ['required_if:document_type,entity-change', 'date'], 'changed_company_name' => ['required_if:document_type,entity-change', 'string', 'max:255'], 'changed_company_start_date' => ['required_if:document_type,entity-change', 'date'], - 'entity_proof_date' => ['required_if:document_type,entity-change', 'date'], 'contract_partner_name' => ['required_if:document_type,shared-contract', 'string', 'max:255'], 'shared_courier_names' => ['nullable', 'array', 'min:1'], 'shared_courier_names.*' => ['required', 'string', 'max:255'], 'shared_courier_name' => ['nullable', 'string', 'max:255'], - 'shared_proof_date' => ['nullable', 'date'], 'shared_note' => ['nullable', 'string', 'max:2000'], 'cooperation_party_a_name' => ['required_if:document_type,cooperation-contract', 'string', 'max:255'], 'cooperation_party_a_phone' => ['required_if:document_type,cooperation-contract', 'string', 'max:64'], @@ -342,7 +341,7 @@ class CourierAgreementSignatures extends Controller ->findOrFail((int) $data['quote_version_id']); $this->ensureQuoteVersionMatchesCourier($version, $data['courier_name']); - $signature = $this->quoteSignatureService->prepareVersion($version, user_id()); + $signature = $this->quoteSignatureService->prepareVersion($version, user_id(), $data['signature_date']); $account = trim((string) $data['esign_account']); $signature = $this->quoteSignatureService->send($signature, [ 'organization_name' => $version->carrier_name, diff --git a/app/Http/Controllers/Business/CourierPayableSummaries.php b/app/Http/Controllers/Business/CourierPayableSummaries.php index 58fc94c0..e0bd8e86 100644 --- a/app/Http/Controllers/Business/CourierPayableSummaries.php +++ b/app/Http/Controllers/Business/CourierPayableSummaries.php @@ -2,18 +2,40 @@ namespace App\Http\Controllers\Business; +use App\Business\CourierPayableSummaryFilter; use App\Business\CourierPayableSummaryStore; use App\Utilities\SidebarPermissions; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Routing\Controller; use RuntimeException; +use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\StreamedResponse; class CourierPayableSummaries extends Controller { - public function __construct(private readonly CourierPayableSummaryStore $store) - { + private const IMPORT_TEMPLATES = [ + 'freight-difference' => [ + 'path' => 'templates/courier-payables/freight-difference-import-template.xlsx', + 'ascii_name' => 'freight_difference_import_template.xlsx', + 'download_name' => '运费差导入模板.xlsx', + ], + 'other-surcharge-summary' => [ + 'path' => 'templates/courier-payables/other-surcharge-import-template.xlsx', + 'ascii_name' => 'other_surcharge_import_template.xlsx', + 'download_name' => '其他附加费导入模板.xlsx', + ], + 'return-fee' => [ + 'path' => 'templates/courier-payables/return-fee-import-template.xlsx', + 'ascii_name' => 'return_fee_import_template.xlsx', + 'download_name' => '退件费导入模板.xlsx', + ], + ]; + + public function __construct( + private readonly CourierPayableSummaryStore $store, + private readonly CourierPayableSummaryFilter $filter, + ) { $this->middleware('auth'); $this->middleware('company.identify'); } @@ -45,7 +67,28 @@ class CourierPayableSummaries extends Controller ); } - public function export(string $summaryPage): StreamedResponse|RedirectResponse + public function template(string $summaryPage): BinaryFileResponse + { + $this->authorizePage($summaryPage); + $template = self::IMPORT_TEMPLATES[$summaryPage] ?? null; + abort_unless(is_array($template), 404); + + $path = resource_path($template['path']); + abort_unless(is_file($path), 404); + + $response = response()->download($path, $template['ascii_name'], [ + 'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Cache-Control' => 'private, no-store', + ]); + + return $response->setContentDisposition( + 'attachment', + $template['download_name'], + $template['ascii_name'], + ); + } + + public function export(Request $request, string $summaryPage): StreamedResponse|RedirectResponse { $this->authorizePage($summaryPage); $title = $this->store->title($summaryPage); @@ -60,7 +103,12 @@ class CourierPayableSummaries extends Controller return $this->redirectToPage($summaryPage)->with('error', '当前没有可导出的汇总数据。'); } - return response()->streamDownload(function () use ($snapshot): void { + $filters = $this->filter->fromRequest($request); + $headers = $snapshot['headers']; + $rows = $this->filter->apply(collect($snapshot['rows']), $headers, $filters)->values(); + $dateColumn = $this->filter->dateColumnIndex($headers); + + return response()->streamDownload(function () use ($headers, $rows, $dateColumn): void { $csv = fopen('php://output', 'w'); if ($csv === false) { @@ -68,10 +116,14 @@ class CourierPayableSummaries extends Controller } fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF)); - fputcsv($csv, array_map([$this, 'csvText'], $snapshot['headers'])); + fputcsv($csv, array_map([$this, 'csvText'], $headers)); - foreach ($snapshot['rows'] as $row) { - fputcsv($csv, array_map([$this, 'csvText'], $row)); + foreach ($rows as $row) { + fputcsv($csv, array_map( + fn (mixed $value, int $column): string => $this->csvText($value, $column === $dateColumn), + $row, + array_keys($row), + )); } fclose($csv); @@ -101,10 +153,14 @@ class CourierPayableSummaries extends Controller abort_unless($pages->contains('slug', $summaryPage), 403, '当前账号没有该页面权限。'); } - private function csvText(mixed $value): string + private function csvText(mixed $value, bool $preserveYearMonth = false): string { $value = (string) $value; + if ($preserveYearMonth && preg_match('/^\d{4}-(?:0[1-9]|1[0-2])$/', $value) === 1) { + return '="' . $value . '"'; + } + if (preg_match('/^[+-]?(?:(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?|\.\d+)%?$/u', $value) === 1) { return $value; } diff --git a/app/Http/Controllers/Business/CourierQuoteSignatures.php b/app/Http/Controllers/Business/CourierQuoteSignatures.php index 98fb9a7d..bc9c31dc 100644 --- a/app/Http/Controllers/Business/CourierQuoteSignatures.php +++ b/app/Http/Controllers/Business/CourierQuoteSignatures.php @@ -19,11 +19,14 @@ class CourierQuoteSignatures extends Controller { } - public function prepare(CourierQuoteVersion $quoteVersion): RedirectResponse + public function prepare(Request $request, CourierQuoteVersion $quoteVersion): RedirectResponse { $this->ensureCanManage(); abort_unless($quoteVersion->status === 'approved', 422, '只有已审核报价版本可以生成签署文件。'); - $signature = $this->service->prepareVersion($quoteVersion, user_id()); + $data = $request->validate([ + 'signature_date' => ['required', 'date'], + ]); + $signature = $this->service->prepareVersion($quoteVersion, user_id(), $data['signature_date']); return redirect()->route('business.courier-quote-changes.show', $quoteVersion->source_change_id) ->with('success', "{$quoteVersion->version_code} 的签署 PDF 已生成,文件校验值已固定。") @@ -40,9 +43,15 @@ class CourierQuoteSignatures extends Controller 'operator_name' => ['required', 'string', 'min:2', 'max:100'], 'operator_account' => ['required', 'string', 'max:255'], 'notice_type' => ['required', 'in:1,2'], + 'signature_date' => ['required', 'date'], 'expires_at' => ['required', 'date', 'after:now', 'before_or_equal:' . now()->addDays(90)->toDateTimeString()], ]); $this->validateAccountForNotice($data['operator_account'], $data['notice_type']); + $quoteSignature = $this->service->prepareVersion( + $quoteSignature->version, + user_id(), + $data['signature_date'], + ); $signature = $this->service->send($quoteSignature, $data, user_id()); $environment = $signature->environment === 'sandbox' ? '沙箱' : '正式环境'; diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index 4b1ba6fc..45068300 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Business; use App\Business\BusinessTableImporter; +use App\Business\CourierPayableSummaryFilter; use App\Business\CourierPayableSummaryStore; use App\Business\FundForecastTemplate; use App\Business\TableRegistry; @@ -16,6 +17,7 @@ use App\Models\Business\CourierQuoteVersion; use App\Models\Business\CourierSigningSubject; use App\Models\Business\InstallationDetail; use App\Models\Business\StoreReceivingAccount; +use App\Business\CourierSurchargeAuditLogRepository; use App\Business\CourierSurchargeQuoteRepository; use App\Business\CourierSurchargeTypeRepository; use App\Models\StoreReceivables\StoreZhongcaiReconciliation; @@ -95,6 +97,7 @@ class Pages extends Controller private readonly UpstreamQueryCacheFactory $upstreamCaches, private readonly CourierSurchargeQuoteRepository $courierSurchargeQuotes, private readonly CourierSurchargeTypeRepository $courierSurchargeTypes, + private readonly CourierSurchargeAuditLogRepository $courierSurchargeAudits, private readonly JushuitanShopBalanceRepository $jushuitanShopBalances, ) { $this->middleware('auth'); @@ -381,6 +384,10 @@ class Pages extends Controller $tabColumn = null; $upstreamError = null; + if (($section['slug'] ?? null) === 'product-inventory' && ($page['slug'] ?? null) === 'inventory-report') { + [$rows, $upstreamError] = $this->inventoryReportRows(); + } + if ($page && $content === null) { $descriptor = TableRegistry::describe( $section['slug'], @@ -389,7 +396,7 @@ class Pages extends Controller ); if ($descriptor) { - if ($descriptor->readOnly) { + if ($descriptor->readOnly) { try { $rows = app(UpstreamBusinessInformation::class)->paginate( $section['slug'], @@ -428,7 +435,11 @@ class Pages extends Controller ]; } - if ($this->isStoreReceivingAccountPage($section['slug'], $page['slug'])) { + if (($section['slug'] ?? null) === 'product-inventory' + && ($page['slug'] ?? null) === 'inventory-report') { + // inventoryReportRows() 已从聚水潭共享镜像按全仓口径取数。 + // 这里必须保留该结果,不能再被目录表的旧演示数据覆盖。 + } elseif ($this->isStoreReceivingAccountPage($section['slug'], $page['slug'])) { try { app(\App\Business\StoreReceivingAccountSynchronizer::class)->sync((int) (company_id() ?: 1)); } catch (Throwable $exception) { @@ -481,6 +492,69 @@ class Pages extends Controller ]; } + /** @return array{0:LengthAwarePaginator,1:?string} */ + private function inventoryReportRows(): array + { + $page = max(1, request()->integer('page', 1)); + $perPage = min(max((int) setting('default.list_limit', 25), 1), 100); + $filters = $this->inventoryReportFilters(request()); + $error = null; + + try { + if (! $this->thirdPartyWarehouseStorage->available()) { + $error = '进销存共享镜像尚未建立,暂时无法读取。'; + } elseif (! $this->thirdPartyWarehouseStorage->allWarehousesReady()) { + $error = '进销存全仓库共享镜像正在执行首次同步,完成前不会展示不完整的单仓数据。'; + } else { + $result = $this->thirdPartyWarehouseStorage->paginateMonthlyInventoryReport( + $filters, + $page, + $perPage, + ); + + return [new LengthAwarePaginator( + $result['data'], + $result['total'], + $perPage, + $page, + [ + 'path' => request()->url(), + 'query' => request()->except('page'), + ], + ), null]; + } + } catch (Throwable $exception) { + report($exception); + $error = '进销存共享镜像暂时无法读取,请稍后重试。'; + } + + return [new LengthAwarePaginator([], 0, $perPage, $page, [ + 'path' => request()->url(), + 'query' => request()->except('page'), + ]), $error]; + } + + /** @return array */ + private function inventoryReportFilters(Request $request): array + { + $month = trim((string) $request->query('month', now()->startOfMonth()->subMonth()->format('Y-m'))); + if (preg_match('/^(\d{4})-(\d{2})$/', $month, $matches) !== 1 + || ! checkdate((int) $matches[2], 1, (int) $matches[1])) { + $month = now()->startOfMonth()->subMonth()->format('Y-m'); + } + $minimumMonth = substr(ThirdPartyWarehouseStorageMirrorRepository::START_DATE, 0, 7); + if ($month < $minimumMonth) { + $month = $minimumMonth; + } + + return [ + 'month' => $month, + 'product_code' => mb_substr(trim((string) $request->query('product_code', '')), 0, 100), + 'warehouse_provider' => mb_substr(trim((string) $request->query('warehouse_provider', '')), 0, 191), + 'category' => mb_substr(trim((string) $request->query('category', '')), 0, 191), + ]; + } + private function isStoreReceivingAccountPage(string $section, string $page): bool { return $section === 'fund-management' && $page === 'store-receiving-accounts'; @@ -634,7 +708,7 @@ class Pages extends Controller 'payable-cloud-warehouse-vehicle-fee' => $this->contentForDingPayableWorkflows(), 'online-daifa-price' => $this->contentForOnlineDaifaPrice(), 'courier-surcharge' => $this->contentForCourierSurcharge(), - 'freight-difference', 'other-surcharge-summary' => $this->contentForCourierPayableSummary($page), + 'freight-difference', 'other-surcharge-summary', 'return-fee' => $this->contentForCourierPayableSummary($page), 'withdrawal-management' => $this->contentForWithdrawalManagement(), 'bill-offset' => app(\Modules\EcommerceFinance\View\BillOffsetPageData::class)->page(), default => null, @@ -653,22 +727,33 @@ class Pages extends Controller $error = '汇总数据暂时无法读取,请重新导入后再试。'; } + $headers = $snapshot['headers'] ?? []; $allRows = collect($snapshot['rows'] ?? []); - $currentPage = max((int) request()->query('page', 1), 1); + $filter = app(CourierPayableSummaryFilter::class); + $filters = $filter->fromRequest(request()); + $filteredRows = $filter->apply($allRows, $headers, $filters); $perPage = 50; + $lastPage = max((int) ceil($filteredRows->count() / $perPage), 1); + $currentPage = min(max((int) request()->query('page', 1), 1), $lastPage); return [ 'view' => 'business.pages.courier-payables.summary-table', 'courierSummaryPage' => $page, 'courierSummaryTitle' => $store->title($page), - 'courierSummaryHeaders' => $snapshot['headers'] ?? [], + 'courierSummaryHeaders' => $headers, 'courierSummaryRows' => new LengthAwarePaginator( - $allRows->forPage($currentPage, $perPage)->values(), - $allRows->count(), + $filteredRows->forPage($currentPage, $perPage)->values(), + $filteredRows->count(), $perPage, $currentPage, - ['path' => request()->url(), 'query' => request()->except('page')], + ['path' => request()->url(), 'query' => array_filter( + $filters, + static fn (string $value): bool => $value !== '', + )], ), + 'courierSummaryFilters' => $filters, + 'courierSummaryHasFilters' => $filter->hasFilters($filters), + 'courierSummarySourceTotal' => $allRows->count(), 'courierSummarySourceName' => $snapshot['source_name'] ?? null, 'courierSummaryImportedAt' => $snapshot['imported_at'] ?? null, 'courierSummaryError' => $error, @@ -800,6 +885,7 @@ class Pages extends Controller 'status' => $clean(request('status', ''), 20), ]; $data = $this->courierSurchargeQuotes->pageData($filters); + $auditReady = $this->courierSurchargeAudits->ready(); return [ 'view' => 'business.pages.courier-payables.courier-surcharge', @@ -807,8 +893,11 @@ class Pages extends Controller 'courierSurchargeFilters' => $filters, 'courierSurchargeSummary' => $data['summary'], 'courierSurchargeOptions' => $data['options'], - 'courierSurchargeReady' => $data['ready'], + 'courierSurchargeReady' => $data['ready'] && $auditReady, + 'courierSurchargeDataReady' => $data['ready'], + 'courierSurchargeAuditReady' => $auditReady, 'courierSurchargeTypeUsage' => $this->courierSurchargeTypes->usageCounts($data['options']['type_records'] ?? collect()), + 'courierSurchargeAuditLogs' => $this->courierSurchargeAudits->recent(), ]; } @@ -986,6 +1075,45 @@ class Pages extends Controller ]); } + public function exportInventoryReport(Request $request): StreamedResponse + { + abort_unless( + $this->thirdPartyWarehouseStorage->available() + && $this->thirdPartyWarehouseStorage->allWarehousesReady(), + 503, + ); + + $filters = $this->inventoryReportFilters($request); + $productInventory = collect(config('business_navigation'))->firstWhere('slug', 'product-inventory'); + $inventoryPage = collect($productInventory['pages'] ?? [])->firstWhere('slug', 'inventory-report'); + $columns = $inventoryPage['directory']['fields'] ?? []; + + return response()->streamDownload(function () use ($filters, $columns): void { + $csv = fopen('php://output', 'w'); + if ($csv === false) { + throw new RuntimeException('无法创建进销存报表导出流。'); + } + + fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF)); + fputcsv($csv, array_column($columns, 'label')); + foreach ($this->thirdPartyWarehouseStorage->exportMonthlyInventoryReportRows($filters) as $row) { + fputcsv($csv, array_map(function (array $column) use ($row): mixed { + $value = data_get($row, $column['name']); + + return $column['name'] === 'product_code' + ? $this->csvIdentifier($value) + : (($column['kind'] ?? null) === 'integer' + ? ($value === null || $value === '' ? '' : $value) + : $this->csvText($value)); + }, $columns)); + } + fclose($csv); + }, '进销存报表_' . str_replace('-', '', $filters['month']) . '_' . now()->format('Ymd_His') . '.csv', [ + 'Content-Type' => 'text/csv; charset=UTF-8', + 'Cache-Control' => 'private, no-store', + ]); + } + /** @return array */ private function thirdPartyWarehouseStorageFilters(Request $request): array { diff --git a/app/Models/Business/CourierSurchargeAuditLog.php b/app/Models/Business/CourierSurchargeAuditLog.php new file mode 100644 index 00000000..70657901 --- /dev/null +++ b/app/Models/Business/CourierSurchargeAuditLog.php @@ -0,0 +1,44 @@ + 'integer', + 'actor_id' => 'integer', + 'target_id' => 'integer', + 'changes' => 'array', + ]; + + public function actor() + { + return $this->belongsTo(user_model_class(), 'actor_id'); + } +} diff --git a/app/Support/CourierAgreementPdf.php b/app/Support/CourierAgreementPdf.php index d64ae502..8629db0d 100644 --- a/app/Support/CourierAgreementPdf.php +++ b/app/Support/CourierAgreementPdf.php @@ -3,17 +3,26 @@ namespace App\Support; use App\Models\Business\CourierAgreementSignature; +use Illuminate\Support\Carbon; use RuntimeException; final class CourierAgreementPdf { public function render(CourierAgreementSignature $signature): string { + $payload = $signature->payload ?? []; + $signatureDate = (string) ($payload['signature_date'] ?? $payload['entity_proof_date'] ?? $payload['shared_proof_date'] ?? ''); + try { + $signatureDate = $signatureDate !== '' ? Carbon::parse($signatureDate)->format('Y年n月j日') : '待填写'; + } catch (\Throwable) { + $signatureDate = '待填写'; + } $html = view('business.pdf.courier-agreement', [ 'signature' => $signature, 'type' => $signature->document_type, 'title' => $this->title($signature), - 'payload' => $signature->payload ?? [], + 'payload' => $payload, + 'signatureDate' => $signatureDate, 'fontPath' => str_replace('\\', '/', resource_path('fonts/DroidSansFallback.ttf')), ])->render(); diff --git a/app/Support/CourierQuotePdf.php b/app/Support/CourierQuotePdf.php index b85b85dc..04c267de 100644 --- a/app/Support/CourierQuotePdf.php +++ b/app/Support/CourierQuotePdf.php @@ -3,6 +3,7 @@ namespace App\Support; use App\Models\Business\CourierQuoteVersion; +use Illuminate\Support\Carbon; use RuntimeException; final class CourierQuotePdf @@ -11,9 +12,10 @@ final class CourierQuotePdf { } - public function render(CourierQuoteVersion $version): string + public function render(CourierQuoteVersion $version, ?string $signatureDate = null): string { $data = $this->confirmation->data($version); + $signatureDate = Carbon::parse($signatureDate ?: now())->format('Y年n月j日'); $html = view('business.pdf.courier-quote', [ 'version' => $version, 'change' => $version->change, @@ -23,6 +25,7 @@ final class CourierQuotePdf 'expiryDate' => $data['expiry_date'], 'faceFee' => $data['face_fee'], 'bubbleCoefficient' => $data['bubble_coefficient'], + 'signatureDate' => $signatureDate, 'generatedAt' => now(), 'fontPath' => str_replace('\\', '/', resource_path('fonts/DroidSansFallback.ttf')), ])->render(); diff --git a/app/Support/CourierQuoteSignatureService.php b/app/Support/CourierQuoteSignatureService.php index 72f60bae..c9bf7b2e 100644 --- a/app/Support/CourierQuoteSignatureService.php +++ b/app/Support/CourierQuoteSignatureService.php @@ -18,11 +18,11 @@ final class CourierQuoteSignatureService { } - public function prepareVersion(CourierQuoteVersion $version, int $userId): CourierQuoteSignature + public function prepareVersion(CourierQuoteVersion $version, int $userId, ?string $signatureDate = null): CourierQuoteSignature { $existing = $version->signature; - if ($existing && $this->sourcePdfIsIntact($existing)) { + if ($signatureDate === null && $existing && $this->sourcePdfIsIntact($existing)) { return $existing; } @@ -32,7 +32,7 @@ final class CourierQuoteSignatureService ]); } - $bytes = $this->pdf->render($version); + $bytes = $this->pdf->render($version, $signatureDate); $path = $this->path($version, 'source.pdf'); $sha256 = hash('sha256', $bytes); diff --git a/app/View/Components/Title.php b/app/View/Components/Title.php index 63a7d9ef..a78647e2 100644 --- a/app/View/Components/Title.php +++ b/app/View/Components/Title.php @@ -60,6 +60,7 @@ class Title extends Component $context = Str::lower($routeName . ' ' . request()->path()); $icons = [ + 'financial-risk-control' => 'security', 'store-receiving-bills' => 'account_balance_wallet', 'marketing-account-bills' => 'account_balance_wallet', 'supplier-payables' => 'local_shipping', diff --git a/config/business_navigation.php b/config/business_navigation.php index 1ea8cea1..bd34ad2a 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -302,6 +302,39 @@ $navigation = [ 'pages' => [ ['slug' => 'normal-products', 'title' => '普通商品资料'], ['slug' => 'online-daifa-price', 'title' => '线上代发价格'], + [ + 'slug' => 'inventory-report', + 'title' => '进销存报表', + 'directory' => [ + 'icon' => 'inventory', + 'status' => '共享镜像', + 'description' => '复用“应付款项 → 快递应付 → 三方仓进销存”的已确认流水、库存锚点和业务类型口径,按所有仓库、商品和月份汇总。', + 'empty' => '进销存共享镜像尚未完成首次同步。', + 'fields' => [ + ['name' => 'owner', 'label' => '货主'], + ['name' => 'month', 'label' => '月份', 'kind' => 'date'], + ['name' => 'warehouse_provider', 'label' => '仓储方'], + ['name' => 'warehouse', 'label' => '仓库'], + ['name' => 'product_code', 'label' => '商品编码'], + ['name' => 'category', 'label' => '分类'], + ['name' => 'opening_quantity', 'label' => '期初数量', 'kind' => 'integer'], + ['name' => 'closing_quantity', 'label' => '期末数量', 'kind' => 'integer'], + ['name' => 'processing_in', 'label' => '加工进仓', 'kind' => 'integer'], + ['name' => 'processing_out', 'label' => '加工出仓', 'kind' => 'integer'], + ['name' => 'purchase_in', 'label' => '采购进仓', 'kind' => 'integer'], + ['name' => 'purchase_return', 'label' => '采购退货', 'kind' => 'integer'], + ['name' => 'other_in', 'label' => '其它进仓', 'kind' => 'integer'], + ['name' => 'other_return', 'label' => '其它退货', 'kind' => 'integer'], + ['name' => 'sales_out', 'label' => '销售出仓', 'kind' => 'integer'], + ['name' => 'sales_return', 'label' => '销售退货', 'kind' => 'integer'], + ['name' => 'stocktake', 'label' => '盘点', 'kind' => 'integer'], + ['name' => 'transfer_out', 'label' => '调拨出', 'kind' => 'integer'], + ['name' => 'transfer_in', 'label' => '调拨入', 'kind' => 'integer'], + ['name' => 'opening', 'label' => '期初', 'kind' => 'integer'], + ['name' => 'other_out', 'label' => '其它出库', 'kind' => 'integer'], + ], + ], + ], ], ], [ @@ -508,6 +541,7 @@ $navigation = [ ['slug' => 'courier-payables-report', 'title' => '快递应付表'], ['slug' => 'freight-difference', 'title' => '运费差'], ['slug' => 'other-surcharge-summary', 'title' => '其他附加费汇总'], + ['slug' => 'return-fee', 'title' => '退件费'], ], ], [ @@ -592,6 +626,13 @@ $navigation = [ ['slug' => 'financial-analysis', 'title' => '财务经营分析表'], ], ], + [ + 'slug' => 'financial-risk-control', + 'title' => '财务经营风险管控', + 'icon' => 'security', + // Platform views are horizontal tabs inside the page, not sidebar entries. + 'pages' => [], + ], [ 'slug' => 'financial-sw', 'title' => '财务SW报表', diff --git a/config/upstream_mirrors.php b/config/upstream_mirrors.php index cc839d6a..2239c7f9 100644 --- a/config/upstream_mirrors.php +++ b/config/upstream_mirrors.php @@ -87,8 +87,8 @@ return [ 'overlap_rows' => (int) env('STORE_REPORT_MIRROR_OVERLAP_ROWS', 50000), ], - // 聚水潭进销存报表:固定只读“三方-南昌樵舍仓”,页面只读共享镜像。 - // 头表按 Id 区间小批读取并固定仓库,避免对上游流水表做无界扫描。 + // 聚水潭进销存共享流水镜像:头表按 Id 区间小批读取全部仓库。 + // 三方仓页面在 Repository 层继续固定南昌樵舍仓,进销存报表按全仓库汇总。 'third_party_warehouse_storage' => [ 'dataset_key' => 'jiansun.third-party-warehouse-storage', 'source_key' => 'jiansun', @@ -222,6 +222,7 @@ return [ ['sku_type', 'autoid'], ['sku_type', 'i_id', 'autoid'], ['sku_type', 'sku_id', 'autoid'], + ['sku_id', 'autoid'], ['sku_type', 'enabled', 'autoid'], ['sku_type', 'stock_disabled', 'autoid'], ], diff --git a/database/migrations/2026_09_11_143000_create_third_party_warehouse_storage_mirror_tables.php b/database/migrations/2026_09_11_143000_create_third_party_warehouse_storage_mirror_tables.php index 9713eaa8..3447a3c2 100644 --- a/database/migrations/2026_09_11_143000_create_third_party_warehouse_storage_mirror_tables.php +++ b/database/migrations/2026_09_11_143000_create_third_party_warehouse_storage_mirror_tables.php @@ -18,6 +18,7 @@ return new class extends Migration if (! $schema->hasTable('third_party_warehouse_sync_states')) { $schema->create('third_party_warehouse_sync_states', function (Blueprint $table): void { $table->string('dataset_key', 100)->primary(); + $table->unsignedTinyInteger('scope_version')->default(1); $table->string('status', 32)->default('pending'); $table->string('run_mode', 24)->nullable(); $table->string('sync_phase', 24)->nullable(); @@ -78,6 +79,7 @@ return new class extends Migration $table->unsignedBigInteger('id'); $table->unsignedBigInteger('io_id'); $table->unsignedBigInteger('wms_co_id'); + $table->unsignedBigInteger('wh_id')->default(0); $table->string('status', 32)->nullable(); $table->string('io_type', 64)->nullable(); $table->dateTime('occurred_at', 3)->nullable(); @@ -90,6 +92,10 @@ return new class extends Migration ['generation', 'status', 'occurred_at', 'io_type'], 'tpw_headers_report_index', ); + $table->index( + ['generation', 'wms_co_id', 'occurred_at', 'io_type'], + 'tpw_headers_warehouse_report_index', + ); }); } diff --git a/database/migrations/2026_09_20_150000_expand_inventory_flow_mirror_to_all_warehouses.php b/database/migrations/2026_09_20_150000_expand_inventory_flow_mirror_to_all_warehouses.php new file mode 100644 index 00000000..34415125 --- /dev/null +++ b/database/migrations/2026_09_20_150000_expand_inventory_flow_mirror_to_all_warehouses.php @@ -0,0 +1,41 @@ +connection()); + + if ($schema->hasTable('third_party_warehouse_inout_headers') + && ! $schema->hasColumn('third_party_warehouse_inout_headers', 'wh_id')) { + $schema->table('third_party_warehouse_inout_headers', function (Blueprint $table): void { + $table->unsignedBigInteger('wh_id')->default(0)->after('wms_co_id'); + $table->index( + ['generation', 'wms_co_id', 'occurred_at', 'io_type'], + 'tpw_headers_warehouse_report_index', + ); + }); + } + } + + public function down(): void + { + $schema = Schema::connection($this->connection()); + if ($schema->hasTable('third_party_warehouse_inout_headers') + && $schema->hasColumn('third_party_warehouse_inout_headers', 'wh_id')) { + $schema->table('third_party_warehouse_inout_headers', function (Blueprint $table): void { + $table->dropIndex('tpw_headers_warehouse_report_index'); + $table->dropColumn('wh_id'); + }); + } + } +}; diff --git a/database/migrations/2026_09_20_151000_add_scope_version_to_inventory_flow_state.php b/database/migrations/2026_09_20_151000_add_scope_version_to_inventory_flow_state.php new file mode 100644 index 00000000..5c33bdd8 --- /dev/null +++ b/database/migrations/2026_09_20_151000_add_scope_version_to_inventory_flow_state.php @@ -0,0 +1,35 @@ +connection()); + if ($schema->hasTable('third_party_warehouse_sync_states') + && ! $schema->hasColumn('third_party_warehouse_sync_states', 'scope_version')) { + $schema->table('third_party_warehouse_sync_states', function (Blueprint $table): void { + $table->unsignedTinyInteger('scope_version')->default(1)->after('dataset_key'); + }); + } + } + + public function down(): void + { + $schema = Schema::connection($this->connection()); + if ($schema->hasTable('third_party_warehouse_sync_states') + && $schema->hasColumn('third_party_warehouse_sync_states', 'scope_version')) { + $schema->table('third_party_warehouse_sync_states', function (Blueprint $table): void { + $table->dropColumn('scope_version'); + }); + } + } +}; diff --git a/database/migrations/2026_09_20_181000_create_inventory_subwarehouse_snapshots.php b/database/migrations/2026_09_20_181000_create_inventory_subwarehouse_snapshots.php new file mode 100644 index 00000000..ebaf23be --- /dev/null +++ b/database/migrations/2026_09_20_181000_create_inventory_subwarehouse_snapshots.php @@ -0,0 +1,61 @@ +connection()); + if ($schema->hasTable('third_party_warehouse_inventory_subwarehouse_snapshots')) { + return; + } + + $schema->create('third_party_warehouse_inventory_subwarehouse_snapshots', function (Blueprint $table): void { + $table->bigIncrements('_mirror_id'); + $table->uuid('generation'); + $table->string('source_key', 64)->default('jiansun'); + $table->unsignedBigInteger('id'); + $table->unsignedBigInteger('wms_co_id'); + $table->unsignedSmallInteger('wh_id'); + $table->string('sku_code', 191); + $table->decimal('qty', 20, 4)->default(0); + $table->dateTime('source_modified_at', 3)->nullable(); + $table->dateTime('captured_at', 6); + $table->string('row_hash', 64); + $table->dateTime('synced_at', 6); + $table->unique(['generation', 'id', 'wh_id'], 'tpw_subinventory_generation_id_wh_unique'); + $table->index( + ['generation', 'wms_co_id', 'wh_id', 'sku_code'], + 'tpw_subinventory_report_index', + ); + $table->index(['generation', 'sku_code'], 'tpw_subinventory_sku_index'); + }); + + $connection = DB::connection($this->connection()); + $connection->table('third_party_warehouse_inventory_subwarehouse_snapshots')->insertUsing( + [ + 'generation', 'source_key', 'id', 'wms_co_id', 'wh_id', 'sku_code', 'qty', + 'source_modified_at', 'captured_at', 'row_hash', 'synced_at', + ], + $connection->table('third_party_warehouse_inventory_snapshots')->select([ + 'generation', 'source_key', 'id', 'wms_co_id', DB::raw('1'), 'sku_code', 'qty', + 'source_modified_at', 'captured_at', 'row_hash', 'synced_at', + ]), + ); + } + + public function down(): void + { + Schema::connection($this->connection()) + ->dropIfExists('third_party_warehouse_inventory_subwarehouse_snapshots'); + } +}; diff --git a/database/migrations/2026_09_21_000000_create_courier_surcharge_audit_logs_table.php b/database/migrations/2026_09_21_000000_create_courier_surcharge_audit_logs_table.php new file mode 100644 index 00000000..33f5a91e --- /dev/null +++ b/database/migrations/2026_09_21_000000_create_courier_surcharge_audit_logs_table.php @@ -0,0 +1,36 @@ +increments('id'); + $table->unsignedInteger('company_id'); + $table->unsignedInteger('actor_id')->nullable(); + $table->string('action', 32); + $table->string('target_type', 32); + $table->unsignedInteger('target_id')->nullable(); + $table->string('target_label', 191); + $table->text('changes')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['company_id', 'created_at'], 'courier_surcharge_audit_company_created_index'); + $table->index(['company_id', 'target_type', 'target_id'], 'courier_surcharge_audit_target_index'); + }); + } + + public function down(): void + { + Schema::dropIfExists('business_courier_surcharge_audit_logs'); + } +}; diff --git a/database/migrations/2026_09_21_010000_create_inventory_report_authoritative_tables.php b/database/migrations/2026_09_21_010000_create_inventory_report_authoritative_tables.php new file mode 100644 index 00000000..8d2e48f9 --- /dev/null +++ b/database/migrations/2026_09_21_010000_create_inventory_report_authoritative_tables.php @@ -0,0 +1,75 @@ +connection()); + + if (! $schema->hasTable('inventory_report_authoritative_states')) { + $schema->create('inventory_report_authoritative_states', function (Blueprint $table): void { + $table->string('dataset_key', 120)->primary(); + $table->string('status', 24)->default('pending'); + $table->uuid('active_generation')->nullable(); + $table->uuid('pending_generation')->nullable(); + $table->string('source_filename', 255)->nullable(); + $table->char('source_sha256', 64)->nullable(); + $table->date('source_month')->nullable(); + $table->unsignedBigInteger('source_row_count')->default(0); + $table->dateTime('last_attempted_at')->nullable(); + $table->dateTime('last_succeeded_at')->nullable(); + $table->string('last_error_code', 120)->nullable(); + $table->timestamps(); + }); + } + + if (! $schema->hasTable('inventory_report_authoritative_rows')) { + $schema->create('inventory_report_authoritative_rows', function (Blueprint $table): void { + $table->uuid('generation'); + $table->char('row_key', 64); + $table->date('report_month'); + $table->string('owner', 191); + $table->string('warehouse_provider', 191); + $table->string('warehouse', 191); + $table->string('product_code', 191); + $table->string('category', 191)->nullable(); + $table->decimal('opening_quantity', 20, 4); + $table->decimal('closing_quantity', 20, 4); + foreach ([ + 'processing_in', 'processing_out', 'purchase_in', 'purchase_return', + 'other_in', 'other_return', 'sales_out', 'sales_return', 'stocktake', + 'transfer_out', 'transfer_in', 'opening', 'other_out', + ] as $column) { + $table->decimal($column, 20, 4)->default(0); + } + $table->dateTime('imported_at'); + + $table->primary(['generation', 'row_key'], 'inventory_report_authoritative_rows_primary'); + $table->index( + ['generation', 'report_month', 'product_code'], + 'inventory_report_authoritative_product_index', + ); + $table->index( + ['generation', 'report_month'], + 'inventory_report_authoritative_month_index', + ); + }); + } + } + + public function down(): void + { + $schema = Schema::connection($this->connection()); + $schema->dropIfExists('inventory_report_authoritative_rows'); + $schema->dropIfExists('inventory_report_authoritative_states'); + } +}; diff --git a/docs/上游业务镜像清单.md b/docs/上游业务镜像清单.md index 66974ada..e672dfb8 100644 --- a/docs/上游业务镜像清单.md +++ b/docs/上游业务镜像清单.md @@ -12,7 +12,7 @@ | --- | --- | | 已完成 | `operations_replica/rpt.Bulk_Price_Limit` 已进入类型化表 `product_price_mirror_rows`,商品价格页面已读取镜像并支持完整分页。 | | 已完成 | `operations_replica/rpt.jst_sku` 已进入 `ops_jst_sku`,普通商品资料页面固定读取 `sku_type = normal` 的共享镜像,并支持稳定分页和业务筛选。 | -| 本次接入 | 聚水潭进销存“商品 + 按天”数据固定读取 `jiansun/dbo.jst_inoutWater + dbo.jst_inoutWaterItem + dbo.jst_inventoryInfo` 中仓库 `三方-南昌樵舍仓`(`wms_co_id = 15045364`),进入专用流水、明细、库存快照及同步状态镜像。页面位于“快递应付 / 三方仓进销存”,仅展示 2026-08-01 起的数据;按月或单日选择后均输出“日期 + 商品”明细,并支持分页、筛选、数量汇总和导出。 | +| 本次接入 | 聚水潭进销存共享镜像读取 `jiansun/dbo.jst_inoutWater + dbo.jst_inoutWaterItem + dbo.jst_inventoryInfo` 的全部仓库,保留 2026-08-01 起的已确认流水与库存锚点。“快递应付 / 三方仓进销存”在查询层继续固定 `wms_co_id = 15045364`;“商品价格管理 / 进销存报表”按所有仓库、商品和月份汇总。 | | 已接入校准 | 聚水潭“普通商品资料”完整 XLSX 导出通过 `product-catalog:import-authoritative-export` 流式导入;导出中不存在的经营分析副本历史行写入 `product_catalog_exclusions`,页面与导出统一排除,但不删除 `ops_jst_sku` 原始镜像行。 | | 本次接入 | `jiansun/dbo.jst_purchase + dbo.jst_purchaseItem` 通过固定联表查询进入 `product_purchase_price_mirror_rows`;普通商品资料只读取 `Finished` 且带“1688一键代发”标签的最近采购单价,并与原商品采购价分别标注来源。 | | 本次接入 | 工作平台快递资料页面读取 `wp_express` 主表,并预留网点 `wp_express_expNet`、打款账户 `wp_express_account` 标签;三张表均登记为独立镜像数据集,首次同步完成前页面明确显示同步状态。 | @@ -64,7 +64,7 @@ | 营销账户 | `dbo.tmall_spread_bills`、`dbo.pdd_spread_bills`、`dbo.alibaba_bill`、`dbo.jd_spreadBills`、`dbo.jd_costBills` | | 供应商应付 | `dbo.finance_purchaseReturn`、`dbo.finance_invoiceDeduction`、`dbo.fys_supplierPaymentSummary`、`dbo.finance_advanceLoans`、`dbo.pm_unOrderDetails`、`dbo.finance_sampleDeduction`、`dbo.fys_express_courier_settlement` | | 快递运费对账明细 | `dbo.jst_order_express_details`、`dbo.Jst_SendData_View_extension`、`dbo.jst_salesOutboundInfo` | -| 三方仓进销存 | `dbo.jst_inoutWater`、`dbo.jst_inoutWaterItem`、`dbo.jst_inventoryInfo`(仅 `wms_co_id = 15045364`、`io_date >= 2026-08-01`;专用稳定游标与完整代次镜像) | +| 聚水潭进销存 | `dbo.jst_inoutWater`、`dbo.jst_inoutWaterItem`、`dbo.jst_inventoryInfo`(全部仓库、`io_date >= 2026-08-01`;专用稳定游标与完整代次镜像。三方仓页面仍固定 `wms_co_id = 15045364`,商品价格管理的进销存报表读取全仓库) | | 安装明细 | `dbo.fys_Ticketing_install_view`、`dbo.fys_Ticketing_install_item` | | 板材 | `dbo.finance_boardDeduction`、`dbo.pm_plainPlateData`、`dbo.pm_boardPurchasePrice`、`dbo.pm_boardPriceChangeImport`、`dbo.pm_boardApplyDetail` | | 封边条 | `dbo.pm_edgeBandData`、`dbo.pm_edgeBandApplyDetail`、`dbo.pm_edgeBandProfitInfo`、`dbo.finance_edgeBandDeductio`、`dbo.pm_averageUnitPrice` | @@ -98,7 +98,7 @@ 10. `operations_replica.rpt.jst_originalBillInfo` 是供多个业务功能复用的原始账单镜像。首次同步由 Codex 备份后自主安排在生产同步节点低峰期分批续传,无需另行授权;页面请求和普通开发机不启动刷新 worker。同步完成后,开发者通过固定 Repository 查询 `ops_jst_original_bill_info`,不得各自创建整表快照。 淘系支付宝账户账单当前仍读取此 MariaDB 镜像,统一调度须持续增量,不因 ClickHouse 尚未启用而暂停。账单冲减则复用“订单数据 → 原始数据 → 订单售后费用明细”,不按该表费用名称推断 500112。 11. 拼多多货款账户账单使用 `jiansun.dbo.jst_originalBillInfo.pinduoduo_receiving` 数据集。同步器必须以参数绑定固定 `channel = PinduoduoImport`,只复制该渠道切片;页面 Repository 只读 `js_jst_pdd_receiving_bill_rows`。首次全量按 `Id` 断点推进,后续按 `updateTime` 保留重叠窗口,不得切回落后一天的 `operations_replica` 副本。 12. 得物货款账户账单使用 `jiansun.dbo.jst_originalBillInfo.dewu_receiving` 数据集。同步器必须以参数绑定固定 `channel IN (Dewu, DewuTK, DewuKJ)`,页面 Repository 对列表、分页、筛选、汇总和导出使用相同渠道范围,并保留原始渠道代码用于审计;`Dewubzj` 保证金不得混入。 -13. 三方仓进销存首次全量按头表 `Id` 区间小批断点续传,头表只保存 `io_date >= 2026-08-01` 的目标仓数据,不激活半份数据;日常增量以头表 `(modified, Id)` 作稳定游标并保留 48 小时时间重叠,由命中头单驱动整单明细替换。全量和增量都先写待发布代次,库存抓取完成后再把流水追平到库存抓取之后的固定源时间边界,三部分成功后才一次切换;运行中或失败时页面始终读取上一成功代次。增量扫描不先限定仓库或业务日期,确保单据移出目标仓或日期改到起始日前时可从镜像撤回,重载当前版本时再固定仓库和起始日期。页面只统计 `Confirmed` 的 12 类进销存流水,数量按上游 `qty * qty_type` 保留符号。库存同时保存上游 `source_modified_at` 和实际 `captured_at`,历史余额以每个 SKU 的抓取时间为回推锚点;页面始终按“日期 + 商品”输出,日期区间固定为 `[当天 00:00, 次日 00:00)`。每天 `00:00` 执行增量同步,失败时继续展示上一成功代次。 +13. 聚水潭进销存首次全量按头表 `Id` 区间小批断点续传,头表保存全部仓库中 `io_date >= 2026-08-01` 的数据,不激活半份数据;日常增量以头表 `(modified, Id)` 作稳定游标并保留 48 小时时间重叠,由命中头单驱动整单明细替换。全量和增量都先写待发布代次,库存抓取完成后再把流水追平到库存抓取之后的固定源时间边界,三部分成功后才一次切换;运行中或失败时三方仓页面继续读取上一成功代次,全仓报表在首份完整全仓代次发布前明确提示同步中。页面只统计 `Confirmed` 的 12 类进销存流水,数量按上游 `qty * qty_type` 保留符号。库存同时保存上游 `source_modified_at` 和实际 `captured_at`,历史余额以每个“仓库 + SKU”的抓取时间为回推锚点;三方仓页面仍固定 `wms_co_id = 15045364` 并按“日期 + 商品”输出,全仓报表按“月份 + 仓库 + 商品”汇总。每天 `00:00` 执行增量同步,失败时继续展示上一成功代次。 14. 聚水潭商品操作日志按最多 7 天的固定时间窗和接口页码稳定断点同步,每页最多 50 条,首次全量从 `2000-01-01` 起扫描;只保留备注内容含“采购价”的记录,并从“采购价:旧值->新值”解析前后价格和差额。日常同步回看 48 小时,按接口记录字段生成稳定键幂等更新;接口失败时保留上一版镜像。页面不得把这些聚水潭记录与 `jiansun.dbo.pm_priceAdjustment` 的工作平台价格记录混为同一来源。 工作平台采购价格首次全量需在备份和负责人授权后单独执行: diff --git a/modules/EcommerceFinance/Http/Controllers/AlipayBills.php b/modules/EcommerceFinance/Http/Controllers/AlipayBills.php index 34520ed5..ab0e08c1 100644 --- a/modules/EcommerceFinance/Http/Controllers/AlipayBills.php +++ b/modules/EcommerceFinance/Http/Controllers/AlipayBills.php @@ -16,17 +16,17 @@ class AlipayBills extends Controller 'store_name' => '原始店铺', 'custom_remark' => '自定义备注', 'bill_item' => '账单项目', - 'alipay_account' => '平台渠道', + 'alipay_account' => '支付宝账号', 'account_order_no' => '账务订单号', 'business_serial_no' => '业务流水号', 'merchant_order_no' => '商户订单号', 'business_base_order_no' => '业务基础订单号', - 'goods_name' => '商品编码', + 'goods_name' => '商品名称', 'counterparty_account' => '对方账号', 'occurred_at' => '发生时间', 'income' => '收入', 'expense' => '支出', - 'balance' => '收支净额', + 'balance' => '余额', 'business_type' => '业务类型', 'remark' => '备注', 'online_order_no' => '线上单号', @@ -52,7 +52,7 @@ class AlipayBills extends Controller $dir = $request->get('dir') === 'asc' ? 'asc' : 'desc'; $filters = $request->validate([ 'store_name' => ['nullable', 'string', 'max:255'], - 'alipay_account' => ['nullable', 'string', 'max:128'], + 'source_channel' => ['nullable', 'string', 'max:128'], 'bill_item' => ['nullable', 'string', 'max:255'], 'business_type' => ['nullable', 'string', 'max:255'], 'start' => ['nullable', 'date_format:Y-m-d'], @@ -90,7 +90,7 @@ class AlipayBills extends Controller $this->authorizeStoreReceivingBills(); $filters = $request->validate([ 'store_name' => ['nullable', 'string', 'max:255'], - 'alipay_account' => ['nullable', 'string', 'max:128'], + 'source_channel' => ['nullable', 'string', 'max:128'], 'bill_item' => ['nullable', 'string', 'max:255'], 'business_type' => ['nullable', 'string', 'max:255'], 'start' => ['nullable', 'date_format:Y-m-d'], diff --git a/modules/EcommerceFinance/Repositories/StoreReceivingAlipayBillMirrorRepository.php b/modules/EcommerceFinance/Repositories/StoreReceivingAlipayBillMirrorRepository.php index 6b68c570..eb51b30d 100644 --- a/modules/EcommerceFinance/Repositories/StoreReceivingAlipayBillMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/StoreReceivingAlipayBillMirrorRepository.php @@ -3,6 +3,7 @@ namespace Modules\EcommerceFinance\Repositories; use Illuminate\Database\Query\Builder; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry; use RuntimeException; @@ -17,6 +18,7 @@ class StoreReceivingAlipayBillMirrorRepository public const DATASET = 'operations_replica.rpt.jst_originalBillInfo'; public const SHOP_DATASET = 'operations_replica.rpt.jst_shopInfo'; + public const SKU_DATASET = 'operations_replica.rpt.jst_sku'; public function __construct( private readonly BusinessMirrorDatasetRegistry $datasets, @@ -58,7 +60,7 @@ class StoreReceivingAlipayBillMirrorRepository $perPage = min(max($perPage, 1), 100); $direction = $direction === 'asc' ? 'asc' : 'desc'; $sortColumn = [ - 'record_code' => in_array($mapping, ['pinduoduo', 'dewu'], true) ? 'b.auto_id' : 'b.Id', + 'record_code' => in_array($mapping, ['pinduoduo', 'dewu'], true) ? 'b.auto_id' : ($mapping === 'taobao' ? 'b.p_auto_id' : 'b.Id'), 'occurred_at' => 'b.fee_time', 'income' => 'b.amount', 'expense' => 'b.amount', @@ -88,8 +90,10 @@ class StoreReceivingAlipayBillMirrorRepository $query, $sortColumn, $direction, $page, $perPage, $filters, $channel, $targetTable, ); $shopNames = $this->shopNames($billRows); + $productNames = $mapping === 'taobao' ? $this->productNames($billRows) : []; + $importedRows = $mapping === 'taobao' ? $this->importedRows($billRows) : []; $rows = collect($billRows) - ->map(fn (object $row): array => $this->mapRowFor($row, $shopNames[(string) $row->shop_id] ?? null, $mapping)) + ->map(fn (object $row): array => $this->mapRowFor($row, $shopNames[(string) $row->shop_id] ?? null, $mapping, $productNames, $importedRows)) ->all(); $income = $summary['income']; $expense = $summary['expense']; @@ -146,7 +150,7 @@ class StoreReceivingAlipayBillMirrorRepository $shopNames = $this->allShopNames(); $direction = $direction === 'desc' ? 'desc' : 'asc'; $sortColumn = [ - 'record_code' => in_array($mapping, ['pinduoduo', 'dewu'], true) ? 'b.auto_id' : 'b.Id', + 'record_code' => in_array($mapping, ['pinduoduo', 'dewu'], true) ? 'b.auto_id' : ($mapping === 'taobao' ? 'b.p_auto_id' : 'b.Id'), 'occurred_at' => 'b.fee_time', 'income' => 'b.amount', 'expense' => 'b.amount', @@ -238,8 +242,10 @@ class StoreReceivingAlipayBillMirrorRepository } $rows = $batch->select('b.*')->orderBy($sortColumn, $direction) ->orderBy('b.Id', $direction)->limit(1000)->get(); + $productNames = $mapping === 'taobao' ? $this->productNames($rows->all()) : []; + $importedRows = $mapping === 'taobao' ? $this->importedRows($rows->all()) : []; foreach ($rows as $row) { - yield $this->mapRowFor($row, $shopNames[(string) $row->shop_id] ?? null, $mapping); + yield $this->mapRowFor($row, $shopNames[(string) $row->shop_id] ?? null, $mapping, $productNames, $importedRows); } $last = $rows->last(); } while ($rows->count() === 1000); @@ -321,7 +327,7 @@ class StoreReceivingAlipayBillMirrorRepository private function dailySummarySupports(array $filters): bool { - return ($filters['alipay_account'] ?? '') === '' + return ($filters['source_channel'] ?? '') === '' && ($filters['bill_item'] ?? '') === '' && ($filters['keyword'] ?? '') === ''; } @@ -377,8 +383,8 @@ class StoreReceivingAlipayBillMirrorRepository $query->whereIn('b.shop_id', $shopIds); } } - if (($filters['alipay_account'] ?? '') !== '') { - $query->where('b.channel', 'like', '%' . $this->escapeLike((string) $filters['alipay_account']) . '%'); + if (($filters['source_channel'] ?? '') !== '') { + $query->where('b.channel', 'like', '%' . $this->escapeLike((string) $filters['source_channel']) . '%'); } if (($filters['bill_item'] ?? '') !== '') { $query->where('b.fee_name', 'like', '%' . $this->escapeLike((string) $filters['bill_item']) . '%'); @@ -488,6 +494,47 @@ class StoreReceivingAlipayBillMirrorRepository ->all(); } + /** @param array $rows @return array */ + private function productNames(array $rows): array + { + $skuIds = collect($rows)->map(fn (object $row): ?string => $row->sku_id ?: $row->shop_sku_id) + ->filter()->unique()->values()->all(); + if ($skuIds === []) return []; + + try { + $dataset = $this->datasets->get(self::SKU_DATASET); + } catch (\Throwable) { + return []; + } + if (! $this->mirror->ready($dataset) || ! Schema::connection($this->mirror->connection()->getName())->hasTable('ops_jst_sku')) { + return []; + } + + return $this->mirror->connection()->table('ops_jst_sku') + ->whereIn('sku_id', $skuIds)->pluck('name', 'sku_id') + ->mapWithKeys(static fn (mixed $name, mixed $id): array => [(string) $id => (string) $name]) + ->all(); + } + + /** @param array $rows @return array */ + private function importedRows(array $rows): array + { + if (! Schema::hasTable('ec_alipay_bills')) { + return []; + } + + $recordCodes = collect($rows)->pluck('p_auto_id')->filter()->unique()->values()->all(); + if ($recordCodes === []) { + return []; + } + + return DB::table('ec_alipay_bills') + ->where('company_id', (int) (company_id() ?: 1)) + ->whereNull('deleted_at') + ->whereIn('record_code', $recordCodes) + ->get()->keyBy(fn (object $row): string => (string) $row->record_code)->all(); + } + /** @param array $rows */ public function mapDouyinRows(array $rows): array { @@ -495,44 +542,63 @@ class StoreReceivingAlipayBillMirrorRepository return array_map(fn (object $row): array => $this->mapRowFor($row, $shops[(string) $row->shop_id] ?? null, 'douyin'), $rows); } - private function mapRow(object $row, ?string $shopName): array + private function mapRow(object $row, ?string $shopName, array $productNames = [], array $importedRows = []): array { $amount = abs((float) ($row->amount ?? 0)); $income = (string) ($row->inout_type ?? '') === '1' ? $amount : 0.0; $expense = (string) ($row->inout_type ?? '') === '-1' ? $amount : 0.0; - return [ + $mapped = [ 'id' => (string) $row->Id, - 'record_code' => (string) $row->Id, + 'record_code' => (string) ($row->p_auto_id ?: $row->Id), 'store_name' => $shopName, 'custom_remark' => null, 'bill_item' => $row->fee_name, - 'alipay_account' => $row->channel, - 'account_order_no' => $row->primary_key, - 'business_serial_no' => $row->auto_id, - 'merchant_order_no' => $row->p_auto_id, - 'business_base_order_no' => $row->so_id, - 'goods_name' => $row->sku_id ?: $row->shop_sku_id, + 'alipay_account' => null, + 'account_order_no' => null, + 'business_serial_no' => null, + 'merchant_order_no' => null, + 'business_base_order_no' => null, + 'goods_name' => $productNames[(string) ($row->sku_id ?: $row->shop_sku_id)] ?? null, 'counterparty_account' => null, 'occurred_at' => $this->dateTime($row->fee_time), 'income' => $income, 'expense' => $expense, - 'balance' => round($income - $expense, 2), - 'business_type' => $this->directionName($row->inout_type), + 'balance' => null, + 'business_type' => null, 'remark' => null, 'online_order_no' => $row->so_id, 'online_real_store' => $shopName, - 'business_description' => $row->combine_type, - 'business_bill_source' => $row->channel, - 'business_order_no' => $row->p_auto_id, + 'business_description' => null, + 'business_bill_source' => null, + 'business_order_no' => null, 'created_at' => $this->dateTime($row->createTime), 'updated_at' => $this->dateTime($row->updateTime), 'modifier' => '', ]; + + $imported = $importedRows[$mapped['record_code']] ?? null; + if ($imported === null) { + return $mapped; + } + + foreach ([ + 'custom_remark', 'alipay_account', 'account_order_no', 'business_serial_no', + 'merchant_order_no', 'business_base_order_no', 'counterparty_account', 'balance', + 'business_type', 'remark', 'business_description', 'business_bill_source', + 'business_order_no', 'modifier', + ] as $field) { + $mapped[$field] = $imported->{$field} ?? $mapped[$field]; + } + if ($mapped['goods_name'] === null || $mapped['goods_name'] === '') { + $mapped['goods_name'] = $imported->goods_name ?? null; + } + + return $mapped; } /** @return array */ - private function mapRowFor(object $row, ?string $shopName, string $mapping): array + private function mapRowFor(object $row, ?string $shopName, string $mapping, array $productNames = [], array $importedRows = []): array { if ($mapping === 'pinduoduo') { return $this->mapPinduoduoRow($row, $shopName); @@ -543,7 +609,7 @@ class StoreReceivingAlipayBillMirrorRepository } if ($mapping !== 'douyin') { - return $this->mapRow($row, $shopName); + return $this->mapRow($row, $shopName, $productNames, $importedRows); } $amount = abs((float) ($row->amount ?? 0)); diff --git a/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php b/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php index bcf78e80..35d0356c 100644 --- a/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageMirrorRepository.php @@ -32,6 +32,8 @@ class ThirdPartyWarehouseStorageMirrorRepository private const INVENTORY_TABLE = 'third_party_warehouse_inventory_snapshots'; + private const SUBWAREHOUSE_INVENTORY_TABLE = 'third_party_warehouse_inventory_subwarehouse_snapshots'; + private const AUTHORITATIVE_STATE_TABLE = 'third_party_warehouse_authoritative_anchor_states'; private const AUTHORITATIVE_ANCHOR_TABLE = 'third_party_warehouse_authoritative_anchors'; @@ -40,12 +42,22 @@ class ThirdPartyWarehouseStorageMirrorRepository private const AUTHORITATIVE_DATASET_KEY = 'jiansun.third-party-warehouse-storage.authoritative-anchor'; + private const INVENTORY_REPORT_AUTHORITATIVE_STATE_TABLE = 'inventory_report_authoritative_states'; + + private const INVENTORY_REPORT_AUTHORITATIVE_ROW_TABLE = 'inventory_report_authoritative_rows'; + + private const INVENTORY_REPORT_AUTHORITATIVE_DATASET_KEY = 'jiansun.inventory-report.authoritative-month'; + private const PRODUCT_TABLE = 'ops_jst_sku'; + private const WAREHOUSE_TABLE = 'ops_jst_warehouse_info'; + private const GENERIC_STATE_TABLE = 'business_mirror_table_states'; private const PRODUCT_DATASET_KEY = 'operations_replica.rpt.jst_sku'; + private const WAREHOUSE_DATASET_KEY = 'operations_replica.rpt.jst_warehouseInfo'; + public const START_DATE = '2026-08-01'; /** @var array */ @@ -64,6 +76,45 @@ class ThirdPartyWarehouseStorageMirrorRepository 'other_out' => '其它出库', ]; + /** @var array */ + private const SUBWAREHOUSE_SUFFIXES = [ + 1 => '主仓', + 2 => '销退仓', + 3 => '进货仓', + 4 => '次品仓', + 6 => '自定义仓1', + 7 => '自定义仓2', + 8 => '自定义仓3', + ]; + + /** + * 聚水潭“按商品、按仓库、按月”导出中使用的实际子仓名。 + * 其余仓库按“仓储方 + 子仓后缀”生成,不再只显示通用类型名。 + * + * @var array> + */ + private const SPECIAL_SUBWAREHOUSE_NAMES = [ + '常州简尚家居有限公司' => [ + 1 => '配件单仓', + ], + '简尚-横林二仓' => [ + 6 => '客退临时上架仓', + 7 => '数量校验仓', + ], + '简尚-新苏北仓' => [ + 6 => '新苏北客退上架仓', + ], + '简尚-睢宁仓' => [ + 6 => '睢宁客退上架仓', + ], + '简尚-河南洛宁仓' => [ + 6 => '河南洛宁客退上架仓', + ], + '简尚-山东青岗集仓' => [ + 7 => '青岗集客退上架仓', + ], + ]; + /** @var list */ private const OUTPUT_COLUMNS = [ 'owner', 'date', 'sku_code', 'image_url', 'product_name', 'short_name', @@ -123,6 +174,15 @@ class ThirdPartyWarehouseStorageMirrorRepository && $this->dependenciesReady(); } + public function allWarehousesReady(): bool + { + $state = $this->state(); + + return $this->ready() + && (int) ($state['scope_version'] ?? 1) >= 2 + && $this->warehouseDependencyReady(); + } + /** @return array|null */ public function state(): ?array { @@ -218,6 +278,350 @@ class ThirdPartyWarehouseStorageMirrorRepository ]; } + /** + * 商品价格管理的进销存报表与三方仓共用同一套已确认流水、 + * 库存锚点和业务类型口径,只在这里将日明细再汇总为“商品 + 仓库 + 月”。 + * + * @param array $filters + * @return array{data:list,total:int,state:array,sync:array} + */ + public function paginateMonthlyInventoryReport( + array $filters, + int $page, + int $perPage, + bool $includeTotal = true, + ?array $state = null, + ): array + { + $state ??= $this->requireReadyState(); + if ((int) ($state['scope_version'] ?? 1) < 2) { + throw new RuntimeException('inventory_report_all_warehouses_not_ready'); + } + $page = max($page, 1); + $perPage = min(max($perPage, 1), 5000); + $period = $this->period([ + 'dimension' => 'month', + 'period' => $filters['month'] ?? '', + ]); + $authoritative = $this->monthlyInventoryAuthoritativeQuery($period, $filters); + if ($authoritative !== null) { + $total = $includeTotal ? (clone $authoritative)->count() : 0; + $rows = (clone $authoritative) + ->orderBy('warehouse_provider') + ->orderBy('warehouse') + ->orderBy('product_code') + ->forPage($page, $perPage) + ->get() + ->map(static fn (object $row): object => (object) [ + 'owner' => $row->owner, + 'month' => $row->report_month, + 'warehouse_provider' => $row->warehouse_provider, + 'warehouse' => $row->warehouse, + 'product_code' => $row->product_code, + 'category' => $row->category, + 'opening_quantity' => $row->opening_quantity, + 'closing_quantity' => $row->closing_quantity, + 'processing_in' => $row->processing_in, + 'processing_out' => $row->processing_out, + 'purchase_in' => $row->purchase_in, + 'purchase_return' => $row->purchase_return, + 'other_in' => $row->other_in, + 'other_return' => $row->other_return, + 'sales_out' => $row->sales_out, + 'sales_return' => $row->sales_return, + 'stocktake' => $row->stocktake, + 'transfer_out' => $row->transfer_out, + 'transfer_in' => $row->transfer_in, + 'opening' => $row->opening, + 'other_out' => $row->other_out, + ]) + ->all(); + + return [ + 'data' => $rows, + 'total' => $total, + 'state' => $state, + 'sync' => $this->syncStatus($state), + ]; + } + $headerGeneration = $this->generation($state, 'active_header_generation'); + $itemGeneration = $this->generation($state, 'active_item_generation'); + $inventoryGeneration = $this->generation($state, 'active_inventory_generation'); + if ($headerGeneration === null || $itemGeneration === null || $inventoryGeneration === null) { + throw new RuntimeException('inventory_report_mirror_not_ready'); + } + + $anchorAt = $this->stateAnchor($state); + $rangeStart = min($period['start'], $anchorAt); + $rangeEnd = max($period['end'], $anchorAt); + $latestProducts = $this->connection->table(self::PRODUCT_TABLE) + ->where('sku_type', 'normal')->whereNotNull('sku_id')->where('sku_id', '!=', '') + ->groupBy('sku_id')->select('sku_id')->selectRaw('MAX(autoid) AS autoid'); + $latestWarehouses = $this->connection->table(self::WAREHOUSE_TABLE) + ->whereNotNull('wms_co_id')->groupBy('wms_co_id') + ->select('wms_co_id')->selectRaw('MAX(Id) AS Id'); + $inventoryDimensions = $this->connection->table(self::SUBWAREHOUSE_INVENTORY_TABLE) + ->where('generation', $inventoryGeneration) + ->where('qty', '!=', 0) + ->whereNotNull('sku_code')->where('sku_code', '!=', '') + ->select(['wms_co_id', 'wh_id', 'sku_code as inventory_sku_code', 'captured_at']) + ->selectRaw('qty AS snapshot_qty'); + $candidates = $this->connection->query()->fromSub($inventoryDimensions, 'pairs') + ->joinSub($latestProducts, 'latest_product', 'latest_product.sku_id', '=', 'pairs.inventory_sku_code') + ->join(self::PRODUCT_TABLE . ' as s', function ($join): void { + $join->on('s.sku_id', '=', 'latest_product.sku_id') + ->on('s.autoid', '=', 'latest_product.autoid'); + }) + ->select([ + 'pairs.wms_co_id', 'pairs.wh_id', 'pairs.inventory_sku_code', + 's.sku_id as sku_code', 's.category', + ]); + $productCode = trim((string) ($filters['product_code'] ?? '')); + if ($productCode !== '') { + $candidates->where('s.sku_id', $productCode); + } + $warehouseFilter = trim((string) ($filters['warehouse_provider'] ?? '')); + if ($warehouseFilter !== '') { + $warehouseIds = $this->connection->query()->fromSub( + clone $latestWarehouses, + 'filter_latest_warehouse', + )->join(self::WAREHOUSE_TABLE . ' as filter_warehouse', function ($join): void { + $join->on('filter_warehouse.wms_co_id', '=', 'filter_latest_warehouse.wms_co_id') + ->on('filter_warehouse.Id', '=', 'filter_latest_warehouse.Id'); + }); + $this->containsFilter($warehouseIds, 'filter_warehouse.name', $warehouseFilter); + $candidates->whereIn('pairs.wms_co_id', $warehouseIds->select('filter_warehouse.wms_co_id')); + } + $categoryFilter = trim((string) ($filters['category'] ?? '')); + if ($categoryFilter !== '') { + $this->containsFilter($candidates, 's.category', $categoryFilter); + } + + $total = $includeTotal ? (clone $candidates)->count() : 0; + $pagePairs = (clone $candidates) + ->orderBy('pairs.wms_co_id')->orderBy('pairs.wh_id')->orderBy('s.sku_id') + ->forPage($page, $perPage); + $pairRows = (clone $pagePairs)->get(); + if ($pairRows->isEmpty()) { + return [ + 'data' => [], + 'total' => $total, + 'state' => $state, + 'sync' => $this->syncStatus($state), + ]; + } + + $dimensionedPagePairs = $this->connection->query()->fromSub($pagePairs, 'pairs') + ->leftJoinSub($latestWarehouses, 'latest_warehouse', 'latest_warehouse.wms_co_id', '=', 'pairs.wms_co_id') + ->leftJoin(self::WAREHOUSE_TABLE . ' as w', function ($join): void { + $join->on('w.wms_co_id', '=', 'latest_warehouse.wms_co_id') + ->on('w.Id', '=', 'latest_warehouse.Id'); + }) + ->select([ + 'pairs.wms_co_id', 'pairs.wh_id', 'pairs.inventory_sku_code', + 'pairs.sku_code', 'pairs.category', 'w.name as warehouse_name', + ]); + $anchors = $this->connection->query()->fromSub($dimensionedPagePairs, 'pairs') + ->leftJoinSub(clone $inventoryDimensions, 'inventory', function ($join): void { + $join->on('inventory.wms_co_id', '=', 'pairs.wms_co_id') + ->on('inventory.inventory_sku_code', '=', 'pairs.inventory_sku_code') + ->on('inventory.wh_id', '=', 'pairs.wh_id'); + }) + ->select([ + 'pairs.wms_co_id', 'pairs.wh_id', 'pairs.inventory_sku_code', + 'pairs.sku_code', 'pairs.category', 'pairs.warehouse_name', + ]) + ->selectRaw('COALESCE(inventory.snapshot_qty, 0) AS snapshot_qty') + ->selectRaw('COALESCE(inventory.captured_at, ?) AS snapshot_at', [$anchorAt]); + + $movements = $this->confirmedMovements($headerGeneration, $itemGeneration, null) + ->where('h.occurred_at', '>=', $rangeStart) + ->where('h.occurred_at', '<', $rangeEnd) + ->whereIn('h.wms_co_id', $pairRows->pluck('wms_co_id')->unique()->values()->all()) + ->whereIn('i.sku_code', $pairRows->pluck('inventory_sku_code')->unique()->values()->all()) + ->select([ + 'h.wms_co_id', 'h.io_type', 'h.occurred_at', 'i.sku_code as inventory_sku_code', + 'i.qty as movement_qty', 'i.qty_type as movement_qty_type', + ]) + ->selectRaw('COALESCE(NULLIF(h.wh_id, 0), 1) AS wh_id'); + $signed = '(COALESCE(mv.movement_qty, 0) * COALESCE(mv.movement_qty_type, 0))'; + $balanceAt = static fn (string $target): array => [ + 'a.snapshot_qty + COALESCE(SUM(CASE ' + . 'WHEN a.snapshot_at < ? AND mv.occurred_at >= a.snapshot_at AND mv.occurred_at < ? THEN ' . $signed . ' ' + . 'WHEN a.snapshot_at >= ? AND mv.occurred_at >= ? AND mv.occurred_at < a.snapshot_at THEN -' . $signed . ' ' + . 'ELSE 0 END), 0)', + [$target, $target, $target, $target], + ]; + [$openingSql, $openingBindings] = $balanceAt($period['start']); + [$closingSql, $closingBindings] = $balanceAt($period['end']); + $monthly = $this->connection->query()->fromSub($anchors, 'a') + ->leftJoinSub($movements, 'mv', function ($join): void { + $join->on('mv.wms_co_id', '=', 'a.wms_co_id') + ->on('mv.wh_id', '=', 'a.wh_id') + ->on('mv.inventory_sku_code', '=', 'a.inventory_sku_code'); + }) + ->groupBy([ + 'a.wms_co_id', 'a.wh_id', 'a.inventory_sku_code', 'a.sku_code', + 'a.category', 'a.warehouse_name', + 'a.snapshot_qty', 'a.snapshot_at', + ]) + ->select(['a.wms_co_id', 'a.wh_id', 'a.sku_code', 'a.category', 'a.warehouse_name']) + ->selectRaw($openingSql . ' AS opening_qty', $openingBindings) + ->selectRaw($closingSql . ' AS closing_qty', $closingBindings); + foreach (self::MOVEMENT_COLUMNS as $column => $type) { + $monthly->selectRaw( + 'COALESCE(SUM(CASE WHEN mv.occurred_at >= ? AND mv.occurred_at < ? ' + . 'AND mv.io_type = ? THEN ' . $signed . ' ELSE 0 END), 0) AS ' . $column, + [$period['start'], $period['end'], $type], + ); + } + $monthly->selectRaw('0 AS opening_aux'); + + $rows = $this->connection->query()->fromSub($monthly, 'm') + ->select('m.*')->orderBy('m.warehouse_name')->orderBy('m.wh_id')->orderBy('m.sku_code')->get() + ->map(function (object $row) use ($period): object { + $warehouse = trim((string) ($row->warehouse_name ?? '')); + $subwarehouseId = (int) ($row->wh_id ?? 0); + $subwarehouse = $this->subwarehouseName($warehouse, $subwarehouseId); + + return (object) [ + 'owner' => (string) config( + 'upstream_mirrors.third_party_warehouse_storage.owner_name', + '常州简尚家居有限公司', + ), + 'month' => Carbon::parse($period['start'])->toDateString(), + 'warehouse_provider' => $warehouse, + 'warehouse' => $subwarehouse, + 'product_code' => (string) ($row->sku_code ?? ''), + 'category' => (string) ($row->category ?? ''), + 'opening_quantity' => $row->opening_qty, + 'closing_quantity' => $row->closing_qty, + 'processing_in' => $row->processing_in, + 'processing_out' => $row->processing_out, + 'purchase_in' => $row->purchase_in, + 'purchase_return' => $row->purchase_return, + 'other_in' => $row->other_in, + 'other_return' => $row->other_return, + 'sales_out' => $row->sales_out, + 'sales_return' => $row->sales_return, + 'stocktake' => $row->stocktake, + 'transfer_out' => $row->transfer_out, + 'transfer_in' => $row->transfer_in, + 'opening' => $row->opening_aux, + 'other_out' => $row->other_out, + ]; + })->all(); + + return [ + 'data' => $rows, + 'total' => $total, + 'state' => $state, + 'sync' => $this->syncStatus($state), + ]; + } + + private function subwarehouseName(string $warehouseProvider, int $subwarehouseId): string + { + $specialName = self::SPECIAL_SUBWAREHOUSE_NAMES[$warehouseProvider][$subwarehouseId] ?? null; + if ($specialName !== null) { + return $specialName; + } + + $suffix = self::SUBWAREHOUSE_SUFFIXES[$subwarehouseId] ?? '其他子仓(' . $subwarehouseId . ')'; + + return $warehouseProvider === '' ? $suffix : $warehouseProvider . $suffix; + } + + /** + * @param array $filters + * @return Generator + */ + public function exportMonthlyInventoryReportRows(array $filters, int $batchSize = 2000): Generator + { + $batchSize = min(max($batchSize, 1), 5000); + $page = 1; + $exported = 0; + $total = null; + $state = $this->requireReadyState(); + + while (true) { + $result = $this->paginateMonthlyInventoryReport( + $filters, + $page, + $batchSize, + $total === null, + $state, + ); + if ($total === null) { + $total = (int) ($result['total'] ?? 0); + } + + $rows = $result['data'] ?? []; + if ($rows === []) { + return; + } + foreach ($rows as $row) { + yield $row; + } + + $exported += count($rows); + if ($exported >= $total || count($rows) < $batchSize) { + return; + } + $page++; + } + } + + /** + * 用户确认的聚水潭月报保留了“仓储方 + 子仓 + 商品”维度。 + * 命中同月权威数据时整月直接读取,不再用当前库存倒推历史子仓余额。 + * + * @param array{start:string,end:string} $period + * @param array $filters + */ + private function monthlyInventoryAuthoritativeQuery(array $period, array $filters): ?Builder + { + try { + $schema = Schema::connection($this->connection->getName()); + if (! $schema->hasTable(self::INVENTORY_REPORT_AUTHORITATIVE_STATE_TABLE) + || ! $schema->hasTable(self::INVENTORY_REPORT_AUTHORITATIVE_ROW_TABLE)) { + return null; + } + + $month = Carbon::parse($period['start'])->startOfMonth()->toDateString(); + $state = $this->connection->table(self::INVENTORY_REPORT_AUTHORITATIVE_STATE_TABLE) + ->where('dataset_key', self::INVENTORY_REPORT_AUTHORITATIVE_DATASET_KEY) + ->whereNotNull('active_generation') + ->whereDate('source_month', $month) + ->first(); + if ($state === null || trim((string) $state->active_generation) === '') { + return null; + } + + $query = $this->connection->table(self::INVENTORY_REPORT_AUTHORITATIVE_ROW_TABLE) + ->where('generation', (string) $state->active_generation) + ->whereDate('report_month', $month) + ->select([ + 'owner', 'report_month', 'warehouse_provider', 'warehouse', + 'product_code', 'category', 'opening_quantity', 'closing_quantity', + 'processing_in', 'processing_out', 'purchase_in', 'purchase_return', + 'other_in', 'other_return', 'sales_out', 'sales_return', 'stocktake', + 'transfer_out', 'transfer_in', 'opening', 'other_out', + ]); + + $productCode = trim((string) ($filters['product_code'] ?? '')); + if ($productCode !== '') { + $query->where('product_code', $productCode); + } + $this->containsFilter($query, 'warehouse_provider', $filters['warehouse_provider'] ?? ''); + $this->containsFilter($query, 'category', $filters['category'] ?? ''); + + return $query; + } catch (Throwable) { + return null; + } + } + /** * @param array $filters * @return array{count:int,opening_qty:float,closing_qty:float} @@ -536,15 +940,18 @@ class ThirdPartyWarehouseStorageMirrorRepository return $dates; } - private function confirmedMovements(string $headerGeneration, string $itemGeneration): Builder + private function confirmedMovements( + string $headerGeneration, + string $itemGeneration, + ?int $warehouseId = self::WAREHOUSE_ID, + ): Builder { - return $this->connection->table(self::ITEM_TABLE . ' as i') + $query = $this->connection->table(self::ITEM_TABLE . ' as i') ->join(self::HEADER_TABLE . ' as h', function ($join) use ($headerGeneration): void { $join->on('h.io_id', '=', 'i.io_id') ->where('h.generation', '=', $headerGeneration); }) ->where('i.generation', $itemGeneration) - ->where('h.wms_co_id', self::WAREHOUSE_ID) ->where('h.status', 'Confirmed') ->whereIn('h.io_type', array_values(self::MOVEMENT_COLUMNS)) ->whereNotExists(function (Builder $newer) use ($headerGeneration): void { @@ -554,6 +961,12 @@ class ThirdPartyWarehouseStorageMirrorRepository ->whereColumn('newer.id', '>', 'h.id'); }) ->whereNotNull('i.sku_code')->where('i.sku_code', '!=', ''); + + if ($warehouseId !== null) { + $query->where('h.wms_co_id', $warehouseId); + } + + return $query; } /** @@ -775,6 +1188,23 @@ class ThirdPartyWarehouseStorageMirrorRepository } } + private function warehouseDependencyReady(): bool + { + try { + if (! $this->connection->getSchemaBuilder()->hasTable(self::WAREHOUSE_TABLE)) { + return false; + } + + return $this->connection->table(self::GENERIC_STATE_TABLE) + ->where('dataset_key', self::WAREHOUSE_DATASET_KEY) + ->whereNotNull('initial_completed_at') + ->whereIn('status', ['succeeded', 'running', 'failed']) + ->exists(); + } catch (Throwable) { + return false; + } + } + /** @param array $state */ private function generation(array $state, string $key): ?string { diff --git a/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageUpstreamRepository.php b/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageUpstreamRepository.php index a128dfac..6b623dd7 100644 --- a/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageUpstreamRepository.php +++ b/modules/EcommerceFinance/Repositories/ThirdPartyWarehouseStorageUpstreamRepository.php @@ -9,6 +9,7 @@ class ThirdPartyWarehouseStorageUpstreamRepository { public const SOURCE_KEY = 'jiansun'; + /** 三方仓页面仍固定读取该仓;共享流水镜像覆盖全部仓库。 */ public const WAREHOUSE_ID = 15045364; public const START_DATE = '2026-08-01'; @@ -22,8 +23,7 @@ class ThirdPartyWarehouseStorageUpstreamRepository public function headerHighWatermark(): int { return (int) $this->query()->scalar( - 'SELECT COALESCE(MAX([Id]), 0) FROM [dbo].[jst_inoutWater] WHERE [wms_co_id] = ?', - [self::WAREHOUSE_ID], + 'SELECT COALESCE(MAX([Id]), 0) FROM [dbo].[jst_inoutWater]', ); } @@ -39,6 +39,7 @@ SELECT h.[Id] AS source_id, h.[io_id], h.[wms_co_id], + h.[wh_id], h.[status], h.[type] AS io_type, CONVERT(varchar(27), h.[io_date], 126) AS occurred_at, @@ -46,14 +47,13 @@ SELECT FROM [dbo].[jst_inoutWater] h WHERE h.[Id] > ? AND h.[Id] <= ? - AND h.[wms_co_id] = ? AND h.[io_date] >= ? AND NOT EXISTS ( SELECT 1 FROM [dbo].[jst_inoutWater] newer WHERE newer.[io_id] = h.[io_id] AND newer.[Id] > h.[Id] ) ORDER BY h.[Id] -SQL, [$afterId, $throughId, self::WAREHOUSE_ID, self::START_DATE]); +SQL, [$afterId, $throughId, self::START_DATE]); } /** @return list> */ @@ -70,6 +70,7 @@ SQL, [$afterId, $throughId, self::WAREHOUSE_ID, self::START_DATE]); [Id] AS source_id, [io_id], [wms_co_id], + [wh_id], [status], [type] AS io_type, CONVERT(varchar(27), [io_date], 126) AS occurred_at, @@ -103,7 +104,7 @@ SQL, [$cursorAt, $throughAt, $cursorAt, $cursorAt, $cursorId]); foreach (array_chunk($ioIds, 500) as $chunk) { $placeholders = implode(',', array_fill(0, count($chunk), '?')); $batch = $this->query()->select( - 'SELECT h.[Id] AS source_id, h.[io_id], h.[wms_co_id], h.[status], ' + 'SELECT h.[Id] AS source_id, h.[io_id], h.[wms_co_id], h.[wh_id], h.[status], ' . 'h.[type] AS io_type, CONVERT(varchar(27), h.[io_date], 126) AS occurred_at, ' . 'CONVERT(varchar(27), h.[modified], 126) AS modified_at ' . 'FROM [dbo].[jst_inoutWater] h ' @@ -165,8 +166,7 @@ SQL, [$cursorAt, $throughAt, $cursorAt, $cursorAt, $cursorId]); public function inventoryHighWatermark(): int { return (int) $this->query()->scalar( - 'SELECT COALESCE(MAX([Id]), 0) FROM [dbo].[jst_inventoryInfo] WHERE [wms_co_id] = ?', - [self::WAREHOUSE_ID], + 'SELECT COALESCE(MAX([Id]), 0) FROM [dbo].[jst_inventoryInfo]', ); } @@ -185,13 +185,18 @@ SQL, [$cursorAt, $throughAt, $cursorAt, $cursorAt, $cursorId]); [wms_co_id], [sku_id] AS sku_code, [qty], + [return_qty], + [in_qty], + [defective_qty], + [customize_qty_1], + [customize_qty_2], + [customize_qty_3], CONVERT(varchar(27), [modified], 126) AS source_modified_at FROM [dbo].[jst_inventoryInfo] WHERE [Id] > ? AND [Id] <= ? - AND [wms_co_id] = ? ORDER BY [Id] -SQL, [$afterId, $highWatermark, self::WAREHOUSE_ID]); +SQL, [$afterId, $highWatermark]); } private function query(): UpstreamReadOnlyQuery diff --git a/modules/EcommerceFinance/Services/InventoryReportAuthoritativeImporter.php b/modules/EcommerceFinance/Services/InventoryReportAuthoritativeImporter.php new file mode 100644 index 00000000..58810d75 --- /dev/null +++ b/modules/EcommerceFinance/Services/InventoryReportAuthoritativeImporter.php @@ -0,0 +1,462 @@ + */ + private const REQUIRED_HEADERS = [ + '货主', '月份', '仓储方', '仓库', '商品编码', '分类', '期初数量', '期末数量', + '加工进仓', '加工出仓', '采购进仓', '采购退货', '其它进仓', '其它退货', + '销售出仓', '销售退货', '盘点', '调拨出', '调拨入', '期初', '其它出库', + ]; + + /** @var list */ + private const MOVEMENT_HEADERS = [ + '加工进仓', '加工出仓', '采购进仓', '采购退货', '其它进仓', '其它退货', + '销售出仓', '销售退货', '盘点', '调拨出', '调拨入', '期初', '其它出库', + ]; + + private readonly ConnectionInterface $connection; + + public function __construct(DatabaseManager $databases) + { + $this->connection = $databases->connection( + (string) config('upstream_mirrors.connection', 'business_mirror'), + ); + } + + /** @return array{status:string,source_rows:int,source_month:string,generation:string} */ + public function import(string $path): array + { + $path = $this->validatedPath($path); + $this->assertSchemaReady(); + + $generation = (string) Str::uuid(); + $now = now(); + $hash = hash_file('sha256', $path); + if (! is_string($hash)) { + throw new RuntimeException('无法计算进销存月报文件摘要。'); + } + + $this->connection->table(self::STATE_TABLE)->insertOrIgnore([ + 'dataset_key' => self::DATASET_KEY, + 'status' => 'pending', + 'created_at' => $now, + 'updated_at' => $now, + ]); + $this->connection->table(self::STATE_TABLE) + ->where('dataset_key', self::DATASET_KEY) + ->update([ + 'status' => 'running', + 'pending_generation' => $generation, + 'last_attempted_at' => $now, + 'last_error_code' => null, + 'updated_at' => $now, + ]); + + try { + $source = $this->readAndStore($path, $generation, $now); + $this->connection->transaction(function () use ( + $generation, + $path, + $hash, + $source, + $now, + ): void { + $this->connection->table(self::STATE_TABLE) + ->where('dataset_key', self::DATASET_KEY) + ->update([ + 'status' => 'succeeded', + 'active_generation' => $generation, + 'pending_generation' => null, + 'source_filename' => mb_substr(basename($path), 0, 255), + 'source_sha256' => $hash, + 'source_month' => $source['source_month'], + 'source_row_count' => $source['source_rows'], + 'last_succeeded_at' => $now, + 'last_error_code' => null, + 'updated_at' => $now, + ]); + }); + } catch (Throwable $exception) { + $this->connection->table(self::ROW_TABLE) + ->where('generation', $generation) + ->delete(); + $this->connection->table(self::STATE_TABLE) + ->where('dataset_key', self::DATASET_KEY) + ->update([ + 'status' => 'failed', + 'pending_generation' => null, + 'last_error_code' => class_basename($exception), + 'updated_at' => now(), + ]); + + throw $exception; + } + + try { + $this->connection->table(self::ROW_TABLE) + ->where('generation', '!=', $generation) + ->delete(); + } catch (Throwable $exception) { + report($exception); + } + + return [ + 'status' => 'succeeded', + 'source_rows' => $source['source_rows'], + 'source_month' => $source['source_month'], + 'generation' => $generation, + ]; + } + + private function validatedPath(string $path): string + { + $realPath = realpath($path); + if ($realPath === false || ! is_file($realPath) || ! is_readable($realPath)) { + throw new InvalidArgumentException('进销存月报文件不存在或不可读。'); + } + if (strtolower((string) pathinfo($realPath, PATHINFO_EXTENSION)) !== 'xlsx') { + throw new InvalidArgumentException('进销存权威月报导入只支持 XLSX 文件。'); + } + + return $realPath; + } + + private function assertSchemaReady(): void + { + $schema = Schema::connection($this->connection->getName()); + foreach ([self::STATE_TABLE, self::ROW_TABLE] as $table) { + if (! $schema->hasTable($table)) { + throw new RuntimeException('进销存权威月报表尚未完成迁移:' . $table); + } + } + } + + /** @return array{source_rows:int,source_month:string} */ + private function readAndStore(string $path, string $generation, mixed $importedAt): array + { + $archive = new ZipArchive(); + if ($archive->open($path) !== true) { + throw new InvalidArgumentException('无法打开进销存月报 XLSX 文件。'); + } + + try { + $worksheet = $this->firstWorksheet($archive); + $sharedStrings = $this->sharedStrings($path, $archive); + } finally { + $archive->close(); + } + + $reader = new XMLReader(); + if (! $reader->open($this->zipUri($path, $worksheet), null, LIBXML_NONET | LIBXML_COMPACT)) { + throw new InvalidArgumentException('无法读取进销存月报 XLSX 工作表。'); + } + + $columns = []; + $seen = []; + $sourceMonth = null; + $rowCount = 0; + $batch = []; + + try { + while ($reader->read()) { + if ($reader->nodeType !== XMLReader::ELEMENT || $reader->localName !== 'row') { + continue; + } + + $rowNumber = (int) ($reader->getAttribute('r') ?: 0); + $values = $this->row($reader->readOuterXml(), $sharedStrings); + if ($columns === []) { + $columns = $this->headerColumns($values); + if ($columns === [] && $rowNumber < 20) { + continue; + } + if ($columns === []) { + throw new InvalidArgumentException('前 20 行中未找到进销存月报标准表头。'); + } + continue; + } + if ($this->emptyRow($values)) { + continue; + } + + $month = $this->month($values[$columns['月份']] ?? null, $rowNumber); + if ($sourceMonth !== null && $sourceMonth !== $month) { + throw new InvalidArgumentException('进销存权威月报必须只包含一个月份。'); + } + $sourceMonth = $month; + + $owner = $this->requiredText($values[$columns['货主']] ?? null, $rowNumber, '货主'); + $warehouseProvider = $this->requiredText( + $values[$columns['仓储方']] ?? null, + $rowNumber, + '仓储方', + ); + $warehouse = $this->requiredText($values[$columns['仓库']] ?? null, $rowNumber, '仓库'); + $productCode = ProductCatalogAuthoritativeExportImporter::normalizeSkuId( + (string) ($values[$columns['商品编码']] ?? ''), + ); + if ($productCode === '') { + throw new InvalidArgumentException("第 {$rowNumber} 行“商品编码”不能为空。"); + } + + $rowKey = hash('sha256', implode("\0", [ + $month, + $warehouseProvider, + $warehouse, + $productCode, + ])); + if (isset($seen[$rowKey])) { + throw new InvalidArgumentException( + "第 {$rowNumber} 行与第 {$seen[$rowKey]} 行的月份、仓储方、仓库和商品编码重复。", + ); + } + $seen[$rowKey] = $rowNumber; + + $opening = $this->number($values[$columns['期初数量']] ?? null, $rowNumber, '期初数量'); + $closing = $this->number($values[$columns['期末数量']] ?? null, $rowNumber, '期末数量'); + $movement = 0.0; + $movementValues = []; + foreach (self::MOVEMENT_HEADERS as $header) { + $movementValues[$header] = $this->number( + $values[$columns[$header]] ?? null, + $rowNumber, + $header, + ); + $movement += $movementValues[$header]; + } + if (abs(($opening + $movement) - $closing) > 0.0001) { + throw new InvalidArgumentException( + "第 {$rowNumber} 行期初数量加进出数量不等于期末数量。", + ); + } + + $batch[] = [ + 'generation' => $generation, + 'row_key' => $rowKey, + 'report_month' => $month, + 'owner' => mb_substr($owner, 0, 191), + 'warehouse_provider' => mb_substr($warehouseProvider, 0, 191), + 'warehouse' => mb_substr($warehouse, 0, 191), + 'product_code' => mb_substr($productCode, 0, 191), + 'category' => $this->nullableText($values[$columns['分类']] ?? null, 191), + 'opening_quantity' => $opening, + 'closing_quantity' => $closing, + 'processing_in' => $movementValues['加工进仓'], + 'processing_out' => $movementValues['加工出仓'], + 'purchase_in' => $movementValues['采购进仓'], + 'purchase_return' => $movementValues['采购退货'], + 'other_in' => $movementValues['其它进仓'], + 'other_return' => $movementValues['其它退货'], + 'sales_out' => $movementValues['销售出仓'], + 'sales_return' => $movementValues['销售退货'], + 'stocktake' => $movementValues['盘点'], + 'transfer_out' => $movementValues['调拨出'], + 'transfer_in' => $movementValues['调拨入'], + 'opening' => $movementValues['期初'], + 'other_out' => $movementValues['其它出库'], + 'imported_at' => $importedAt, + ]; + $rowCount++; + + if (count($batch) >= 500) { + $this->connection->table(self::ROW_TABLE)->insert($batch); + $batch = []; + } + } + } finally { + $reader->close(); + } + + if ($batch !== []) { + $this->connection->table(self::ROW_TABLE)->insert($batch); + } + if ($columns === [] || $rowCount === 0 || $sourceMonth === null) { + throw new InvalidArgumentException('进销存权威月报中没有可用明细。'); + } + + return ['source_rows' => $rowCount, 'source_month' => $sourceMonth]; + } + + /** @return array */ + private function row(string $xml, array $sharedStrings): array + { + $row = simplexml_load_string($xml); + if (! $row instanceof SimpleXMLElement) { + return []; + } + + $values = []; + foreach ($row->xpath('./*[local-name()="c"]') ?: [] as $cell) { + $reference = (string) ($cell['r'] ?? ''); + if (preg_match('/^([A-Z]+)\d+$/i', $reference, $matches) !== 1) { + continue; + } + $column = Coordinate::columnIndexFromString(strtoupper($matches[1])); + $type = (string) ($cell['t'] ?? ''); + if ($type === 'inlineStr') { + $nodes = $cell->xpath('.//*[local-name()="t"]') ?: []; + $value = implode('', array_map(static fn (SimpleXMLElement $node): string => (string) $node, $nodes)); + } else { + $nodes = $cell->xpath('./*[local-name()="v"]') ?: []; + $raw = isset($nodes[0]) ? (string) $nodes[0] : ''; + $value = $type === 's' ? (string) ($sharedStrings[(int) $raw] ?? '') : $raw; + } + $values[$column] = $value; + } + + return $values; + } + + /** @param array $values @return array */ + private function headerColumns(array $values): array + { + $lookup = []; + foreach ($values as $column => $value) { + $heading = trim((string) $value); + if ($heading !== '') { + $lookup[$heading] = $column; + } + } + foreach (self::REQUIRED_HEADERS as $header) { + if (! isset($lookup[$header])) { + return []; + } + } + + return array_intersect_key($lookup, array_flip(self::REQUIRED_HEADERS)); + } + + /** @param array $values */ + private function emptyRow(array $values): bool + { + foreach ($values as $value) { + if (trim((string) $value) !== '') { + return false; + } + } + + return true; + } + + private function month(mixed $value, int $rowNumber): string + { + try { + $date = is_numeric($value) + ? Carbon::instance(ExcelDate::excelToDateTimeObject((float) $value)) + : Carbon::parse(str_replace('/', '-', trim((string) $value))); + } catch (Throwable) { + throw new InvalidArgumentException("第 {$rowNumber} 行“月份”不是有效日期。"); + } + + return $date->startOfMonth()->toDateString(); + } + + private function requiredText(mixed $value, int $rowNumber, string $label): string + { + $text = trim((string) $value); + if ($text === '') { + throw new InvalidArgumentException("第 {$rowNumber} 行“{$label}”不能为空。"); + } + + return $text; + } + + private function nullableText(mixed $value, int $length): ?string + { + $text = trim((string) $value); + + return $text === '' ? null : mb_substr($text, 0, $length); + } + + private function number(mixed $value, int $rowNumber, string $label): float + { + if ($value === null || trim((string) $value) === '') { + return 0.0; + } + $normalized = str_replace([',', ','], '', trim((string) $value)); + if (! is_numeric($normalized)) { + throw new InvalidArgumentException("第 {$rowNumber} 行“{$label}”不是有效数字。"); + } + + return round((float) $normalized, 4); + } + + /** @return list */ + private function sharedStrings(string $path, ZipArchive $archive): array + { + if ($archive->locateName('xl/sharedStrings.xml') === false) { + return []; + } + + $reader = new XMLReader(); + if (! $reader->open($this->zipUri($path, 'xl/sharedStrings.xml'), null, LIBXML_NONET | LIBXML_COMPACT)) { + throw new InvalidArgumentException('无法读取 XLSX 共享文本。'); + } + + $strings = []; + try { + while ($reader->read()) { + if ($reader->nodeType !== XMLReader::ELEMENT || $reader->localName !== 'si') { + continue; + } + $item = simplexml_load_string($reader->readOuterXml()); + $nodes = $item instanceof SimpleXMLElement + ? ($item->xpath('.//*[local-name()="t"]') ?: []) + : []; + $strings[] = implode('', array_map( + static fn (SimpleXMLElement $node): string => (string) $node, + $nodes, + )); + } + } finally { + $reader->close(); + } + + return $strings; + } + + private function firstWorksheet(ZipArchive $archive): string + { + $worksheets = []; + for ($index = 0; $index < $archive->numFiles; $index++) { + $name = $archive->getNameIndex($index); + if (is_string($name) && preg_match('#^xl/worksheets/sheet\d+\.xml$#', $name) === 1) { + $worksheets[] = $name; + } + } + sort($worksheets, SORT_NATURAL); + if ($worksheets === []) { + throw new InvalidArgumentException('XLSX 文件中没有工作表。'); + } + + return $worksheets[0]; + } + + private function zipUri(string $path, string $entry): string + { + return 'zip://' . str_replace('\\', '/', $path) . '#' . $entry; + } +} diff --git a/modules/EcommerceFinance/Services/ThirdPartyWarehouseStorageSynchronizer.php b/modules/EcommerceFinance/Services/ThirdPartyWarehouseStorageSynchronizer.php index 87945062..e01ca858 100644 --- a/modules/EcommerceFinance/Services/ThirdPartyWarehouseStorageSynchronizer.php +++ b/modules/EcommerceFinance/Services/ThirdPartyWarehouseStorageSynchronizer.php @@ -30,6 +30,14 @@ class ThirdPartyWarehouseStorageSynchronizer private const INVENTORY_TABLE = 'third_party_warehouse_inventory_snapshots'; + private const SUBWAREHOUSE_INVENTORY_TABLE = 'third_party_warehouse_inventory_subwarehouse_snapshots'; + + /** @var array */ + private const SUBWAREHOUSE_QUANTITY_FIELDS = [ + 1 => 'qty', 2 => 'return_qty', 3 => 'in_qty', 4 => 'defective_qty', + 6 => 'customize_qty_1', 7 => 'customize_qty_2', 8 => 'customize_qty_3', + ]; + private readonly ConnectionInterface $connection; private readonly Repository $cache; @@ -263,12 +271,7 @@ class ThirdPartyWarehouseStorageSynchronizer $changedHeaders, )))); $latestHeaders = $this->upstream->latestHeadersForIoIds($changedIoIds, $throughAt); - $targetHeaders = array_values(array_filter( - $latestHeaders, - static fn (array $row): bool => - (int) ($row['wms_co_id'] ?? 0) - === ThirdPartyWarehouseStorageUpstreamRepository::WAREHOUSE_ID, - )); + $targetHeaders = $latestHeaders; $items = $this->upstream->itemsForIoIds(array_map( static fn (array $row): mixed => $row['io_id'] ?? null, $targetHeaders, @@ -395,7 +398,7 @@ class ThirdPartyWarehouseStorageSynchronizer self::HEADER_TABLE, (string) ($state['active_header_generation'] ?? ''), (string) $values['pending_header_generation'], - ['source_key', 'id', 'io_id', 'wms_co_id', 'status', 'io_type', 'occurred_at', 'source_modified_at', 'row_hash', 'synced_at'], + ['source_key', 'id', 'io_id', 'wms_co_id', 'wh_id', 'status', 'io_type', 'occurred_at', 'source_modified_at', 'row_hash', 'synced_at'], ); $this->cloneActiveGeneration( self::ITEM_TABLE, @@ -615,9 +618,14 @@ class ThirdPartyWarehouseStorageSynchronizer fn (array $row): ?array => $this->mapInventory($row, $generation, $now), $rows, ))); + $subwarehouseMapped = []; + foreach ($rows as $row) { + array_push($subwarehouseMapped, ...$this->mapSubwarehouseInventory($row, $generation, $now)); + } $this->connection->transaction(function () use ( $mapped, + $subwarehouseMapped, $generation, $cursor, $now, @@ -625,6 +633,11 @@ class ThirdPartyWarehouseStorageSynchronizer ): void { $this->assertCurrentRunToken($runToken); $this->upsertRows(self::INVENTORY_TABLE, $mapped, ['generation', 'id']); + $this->upsertRows( + self::SUBWAREHOUSE_INVENTORY_TABLE, + $subwarehouseMapped, + ['generation', 'id', 'wh_id'], + ); $this->updateStateForToken([ 'inventory_cursor' => $cursor, 'updated_at' => $now, @@ -692,6 +705,7 @@ class ThirdPartyWarehouseStorageSynchronizer ); $values['header_modified_cursor_id'] = 0; if ($mode === 'full') { + $values['scope_version'] = 2; $values['initial_completed_at'] = $now; $values['last_full_succeeded_at'] = $now; } @@ -991,6 +1005,7 @@ class ThirdPartyWarehouseStorageSynchronizer [self::HEADER_TABLE, 'header_generation', (string) $release->header_generation], [self::ITEM_TABLE, 'item_generation', (string) $release->item_generation], [self::INVENTORY_TABLE, 'inventory_generation', (string) $release->inventory_generation], + [self::SUBWAREHOUSE_INVENTORY_TABLE, 'inventory_generation', (string) $release->inventory_generation], ] as [$table, $column, $generation]) { if ($generation === '' || in_array($generation, $protected, true)) { continue; @@ -1036,6 +1051,7 @@ class ThirdPartyWarehouseStorageSynchronizer 'id' => $id, 'io_id' => $ioId, 'wms_co_id' => $warehouseId, + 'wh_id' => max((int) ($row['wh_id'] ?? 0), 0), 'status' => $this->text($row['status'] ?? null, 32), 'io_type' => $this->text($row['io_type'] ?? null, 64), 'occurred_at' => $this->dateTime($row['occurred_at'] ?? null), @@ -1113,6 +1129,40 @@ class ThirdPartyWarehouseStorageSynchronizer ]; } + /** @return list> */ + private function mapSubwarehouseInventory(array $row, string $generation, mixed $now): array + { + $base = $this->mapInventory($row, $generation, $now); + if ($base === null) { + return []; + } + + $rows = []; + foreach (self::SUBWAREHOUSE_QUANTITY_FIELDS as $warehouseId => $field) { + $quantity = (float) ($row[$field] ?? 0); + if ($quantity == 0.0) { + continue; + } + $values = [ + 'generation' => $generation, + 'source_key' => $base['source_key'], + 'id' => $base['id'], + 'wms_co_id' => $base['wms_co_id'], + 'wh_id' => $warehouseId, + 'sku_code' => $base['sku_code'], + 'qty' => $quantity, + 'source_modified_at' => $base['source_modified_at'], + 'captured_at' => $base['captured_at'], + ]; + $rows[] = $values + [ + 'row_hash' => $this->rowHash($values), + 'synced_at' => $now, + ]; + } + + return $rows; + } + /** * @param list> $rows * @param list $uniqueBy @@ -1226,6 +1276,7 @@ class ThirdPartyWarehouseStorageSynchronizer self::HEADER_TABLE, self::ITEM_TABLE, self::INVENTORY_TABLE, + self::SUBWAREHOUSE_INVENTORY_TABLE, ] as $table) { if (! $schema->hasTable($table)) { throw new RuntimeException('third_party_warehouse_mirror_schema_missing:' . $table); @@ -1275,6 +1326,7 @@ class ThirdPartyWarehouseStorageSynchronizer 'inventory_row_count', 'initial_completed_at', 'last_full_succeeded_at', + 'scope_version', ] as $column) { if (! $schema->hasColumn(self::STATE_TABLE, $column)) { throw new RuntimeException('third_party_warehouse_mirror_state_missing:' . $column); diff --git a/resources/assets/js/components/business/TaobaoAlipayBills.vue b/resources/assets/js/components/business/TaobaoAlipayBills.vue index d2663e30..98afe930 100644 --- a/resources/assets/js/components/business/TaobaoAlipayBills.vue +++ b/resources/assets/js/components/business/TaobaoAlipayBills.vue @@ -10,7 +10,7 @@
- + @@ -71,8 +71,8 @@ import TableColumnSettings from './TableColumnSettings.vue'; const COLUMNS = [ ['记录编码', 'record_code', 'num', '基础信息'], ['原始店铺', 'store_name', 'text', '基础信息'], ['自定义备注', 'custom_remark', 'text', '基础信息'], ['账单项目', 'bill_item', 'tag', '基础信息'], - ['平台渠道', 'alipay_account', 'num', '账户与交易'], ['账务订单号', 'account_order_no', 'num', '账户与交易'], ['业务流水号', 'business_serial_no', 'num', '账户与交易'], ['商户订单号', 'merchant_order_no', 'num', '账户与交易'], ['业务基础订单号', 'business_base_order_no', 'num', '账户与交易'], ['商品编码', 'goods_name', 'num', '账户与交易'], ['对方账号', 'counterparty_account', 'text', '账户与交易'], ['发生时间', 'occurred_at', 'date', '账户与交易'], - ['收入', 'income', 'money_in', '金额(元)'], ['支出', 'expense', 'money_out', '金额(元)'], ['收支净额', 'balance', 'money', '金额(元)'], + ['支付宝账号', 'alipay_account', 'num', '账户与交易'], ['账务订单号', 'account_order_no', 'num', '账户与交易'], ['业务流水号', 'business_serial_no', 'num', '账户与交易'], ['商户订单号', 'merchant_order_no', 'num', '账户与交易'], ['业务基础订单号', 'business_base_order_no', 'num', '账户与交易'], ['商品名称', 'goods_name', 'text', '账户与交易'], ['对方账号', 'counterparty_account', 'text', '账户与交易'], ['发生时间', 'occurred_at', 'date', '账户与交易'], + ['收入', 'income', 'money_in', '金额(元)'], ['支出', 'expense', 'money_out', '金额(元)'], ['余额', 'balance', 'money', '金额(元)'], ['业务类型', 'business_type', 'tag', '业务关联'], ['备注', 'remark', 'text', '业务关联'], ['线上单号', 'online_order_no', 'num', '业务关联'], ['线上单号对应实际店铺', 'online_real_store', 'text', '业务关联'], ['业务描述', 'business_description', 'text', '业务关联'], ['业务账单来源', 'business_bill_source', 'text', '业务关联'], ['业务订单号', 'business_order_no', 'num', '维护信息'], ['创建时间', 'created_at', 'date', '维护信息'], ['修改时间', 'updated_at', 'date', '维护信息'], ['修改人', 'modifier', 'text', '维护信息'], ].map(([label, field, kind, group]) => ({ key: field, label, field, kind, group })); @@ -81,17 +81,17 @@ const SORTABLE = ['occurred_at', 'income', 'expense', 'balance', 'record_code']; export default { name: 'TaobaoAlipayBills', components: { TableColumnSettings }, props: { apiBase: { type: String, required: true }, exportBase: { type: String, required: true }, userKey: { type: [String, Number], required: true } }, - data() { return { loading: false, error: '', rows: [], configuredColumns: [], summary: { income: 0, expense: 0, balance: 0, count: 0 }, total: 0, currentPage: 1, lastPage: 1, perPage: 20, filters: { store_name: '', alipay_account: '', bill_item: '', business_type: '', start: '', end: '', keyword: '' }, sort: { key: 'occurred_at', dir: 'desc' }, exportMode: 'filtered-visible' }; }, + data() { return { loading: false, error: '', rows: [], configuredColumns: [], summary: { income: 0, expense: 0, balance: 0, count: 0 }, total: 0, currentPage: 1, lastPage: 1, perPage: 20, filters: { store_name: '', source_channel: '', bill_item: '', business_type: '', start: '', end: '', keyword: '' }, sort: { key: 'occurred_at', dir: 'desc' }, exportMode: 'filtered-visible' }; }, computed: { defaultColumns() { return COLUMNS; }, visibleColumns() { return this.configuredColumns.filter((column) => column.visible); }, visibleGroups() { return this.visibleColumns.reduce((groups, column) => { const last = groups[groups.length - 1]; if (last && last.label === column.group) last.span += 1; else groups.push({ label: column.group, span: 1 }); return groups; }, []); }, - stats() { return [{ label: '收入合计', value: this.money(this.summary.income), note: `${this.summary.count} 笔交易`, className: 'is-income' }, { label: '支出合计', value: this.money(this.summary.expense), note: '按当前筛选', className: 'is-expense' }, { label: '收支净额', value: this.money(this.summary.balance), note: '收入合计 - 支出合计', className: '' }, { label: '交易笔数', value: this.summary.count, note: '当前筛选结果', className: '' }]; }, + stats() { return [{ label: '收入合计', value: this.money(this.summary.income), note: `${this.summary.count} 笔交易`, className: 'is-income' }, { label: '支出合计', value: this.money(this.summary.expense), note: '按当前筛选', className: 'is-expense' }, { label: '收支差额', value: this.money(this.summary.balance), note: '收入合计 - 支出合计', className: '' }, { label: '交易笔数', value: this.summary.count, note: '当前筛选结果', className: '' }]; }, rangeStart() { return this.total === 0 ? 0 : (this.currentPage - 1) * this.perPage + 1; }, rangeEnd() { return Math.min(this.currentPage * this.perPage, this.total); }, }, mounted() { this.fetch(); }, methods: { async fetch() { this.loading = true; this.error = ''; try { const response = await window.axios.get(this.apiBase, { params: { ...this.filters, sort: this.sort.key, dir: this.sort.dir, limit: this.perPage, page: this.currentPage } }); const body = response.data || {}; this.rows = body.data || []; this.total = body.total || 0; this.currentPage = body.current_page || 1; this.lastPage = body.last_page || 1; this.perPage = body.per_page || this.perPage; this.summary = body.summary || { income: 0, expense: 0, balance: 0, count: 0 }; } catch (error) { this.rows = []; this.total = 0; this.summary = { income: 0, expense: 0, balance: 0, count: 0 }; this.error = (error.response && error.response.data && error.response.data.message) || '淘系支付宝账单暂时无法读取,请稍后重试。'; } finally { this.loading = false; } }, - applyFilters() { this.currentPage = 1; this.fetch(); }, resetFilters() { this.filters = { store_name: '', alipay_account: '', bill_item: '', business_type: '', start: '', end: '', keyword: '' }; this.applyFilters(); }, + applyFilters() { this.currentPage = 1; this.fetch(); }, resetFilters() { this.filters = { store_name: '', source_channel: '', bill_item: '', business_type: '', start: '', end: '', keyword: '' }; this.applyFilters(); }, changePage(page) { this.currentPage = page; this.fetch(); this.$nextTick(() => { if (this.$refs.scroll) this.$refs.scroll.scrollTop = 0; }); }, maybeSort(column) { if (!this.sortableField(column.field)) return; this.sort.dir = this.sort.key === column.field && this.sort.dir === 'desc' ? 'asc' : 'desc'; this.sort.key = column.field; this.applyFilters(); }, sortableField(field) { return SORTABLE.indexOf(field) !== -1; }, sortMark(field) { return this.sort.key === field ? (this.sort.dir === 'asc' ? '↑' : '↓') : '↕'; }, rawValue(row, column) { const value = row[column.field]; return value === null || value === undefined ? '' : value; }, diff --git a/resources/templates/courier-payables/freight-difference-import-template.xlsx b/resources/templates/courier-payables/freight-difference-import-template.xlsx new file mode 100644 index 00000000..6e3df03b Binary files /dev/null and b/resources/templates/courier-payables/freight-difference-import-template.xlsx differ diff --git a/resources/templates/courier-payables/other-surcharge-import-template.xlsx b/resources/templates/courier-payables/other-surcharge-import-template.xlsx new file mode 100644 index 00000000..728aecd7 Binary files /dev/null and b/resources/templates/courier-payables/other-surcharge-import-template.xlsx differ diff --git a/resources/templates/courier-payables/return-fee-import-template.xlsx b/resources/templates/courier-payables/return-fee-import-template.xlsx new file mode 100644 index 00000000..e65abf20 Binary files /dev/null and b/resources/templates/courier-payables/return-fee-import-template.xlsx differ diff --git a/resources/views/business/pages/courier-payables/agreement-signing.blade.php b/resources/views/business/pages/courier-payables/agreement-signing.blade.php index 2ec4a024..861cbea9 100644 --- a/resources/views/business/pages/courier-payables/agreement-signing.blade.php +++ b/resources/views/business/pages/courier-payables/agreement-signing.blade.php @@ -1395,7 +1395,12 @@
- + + +

该日期会直接写入待签文件,并随原文一起推送到 E 签宝。

+
+
+

选择快递公司后自动带出工作平台的“电子签章经办人”(dzqzjbr);如资料未维护,可手工填写对方在 E 签宝实名绑定的手机号或邮箱。

@@ -1417,7 +1422,7 @@

确认单绑定已审核报价版本,生成后保存该版本快照;变价请生成新版本并重新签署。

@@ -1500,10 +1505,6 @@ -
- - -
-

日期:签署后生成

+

日期:{{ now()->format('Y年n月j日') }}

第 1 页 / 共 1 页

@@ -1746,7 +1747,7 @@

双方确认以下报价版本作为快递服务结算依据。报价版本一经签署即形成独立留档,后续变价须生成新版本并重新签署。

请选择报价版本后查看报价明细
-

乙方(快递方)签章:

日期:签署后生成

+

乙方(快递方)签章:

日期:{{ now()->format('Y年n月j日') }}

报价确认单 · 快递方单方签署

@@ -2219,6 +2220,24 @@ }); } + function refreshSignerAccountLabels(templateType) { + var labels = { + primary: '对方 E 签宝账号', + secondary: '第二签署方 E 签宝账号' + }; + + if (templateType === 'cooperation-contract') { + labels.primary = '快递方 E 签宝账号'; + labels.secondary = '我方 E 签宝账号'; + } else if (templateType === 'authorization') { + labels.primary = '委托方 E 签宝账号'; + labels.secondary = '受托方 E 签宝账号'; + } + + setText('[data-primary-esign-label]', labels.primary); + setText('[data-secondary-esign-label]', labels.secondary); + } + function formatDateField(selector, fallback) { var value = fieldValue(selector, ''); var parts = value.split('-'); @@ -2381,6 +2400,7 @@ operatorRequiredMark.hidden = !accountOperatorRequired; } activateTemplate(templateType); + refreshSignerAccountLabels(templateType); var secondary = root.querySelector('[data-secondary-signer-fields]'); if (secondary) { var needsSecondary = templateType === 'entity-change' || templateType === 'cooperation-contract' || templateType === 'authorization'; @@ -2412,7 +2432,7 @@ setText('[data-preview-template-code]', documentOption.dataset.code); setPreviewValue('company', companyName); setPreviewValue('courier', courier); - setPreviewValue('signatureDate', '签署后生成'); + setPreviewValue('signatureDate', formatDateField('[name="signature_date"]', '待填写')); if (templateType === 'account-proof') { setPreviewValue('proofEffectiveDate', formatDateField('[name="account_proof_effective_date"]', '待填写')); @@ -2441,7 +2461,6 @@ setPreviewValue('entityChangeDate', formatDateField('[name="entity_change_date"]', '待填写')); setPreviewValue('changedCompanyName', fieldValue('[name="changed_company_name"]', '待填写')); setPreviewValue('changedCompanyStartDate', formatDateField('[name="changed_company_start_date"]', '待填写')); - setPreviewValue('entityProofDate', formatDateField('[name="entity_proof_date"]', '待填写')); } if (templateType === 'shared-contract') { diff --git a/resources/views/business/pages/courier-payables/courier-surcharge.blade.php b/resources/views/business/pages/courier-payables/courier-surcharge.blade.php index 0aaad980..490f76da 100644 --- a/resources/views/business/pages/courier-payables/courier-surcharge.blade.php +++ b/resources/views/business/pages/courier-payables/courier-surcharge.blade.php @@ -4,7 +4,10 @@ $summary = $courierSurchargeSummary ?? []; $options = $courierSurchargeOptions ?? []; $ready = (bool) ($courierSurchargeReady ?? false); + $dataReady = (bool) ($courierSurchargeDataReady ?? $ready); + $auditReady = (bool) ($courierSurchargeAuditReady ?? $ready); $typeUsage = $courierSurchargeTypeUsage ?? []; + $auditLogs = $courierSurchargeAuditLogs ?? collect(); $statusClass = static fn (string $status): string => $status === '已启用' ? 'is-active' : 'is-inactive'; $columns = [ ['key' => 'id', 'label' => '规则编号', 'fixed' => true, 'exportable' => true], @@ -49,6 +52,13 @@ .sm-actions { display:flex; flex-wrap:wrap; gap:.45rem; } .sm-button { display:inline-flex; align-items:center; justify-content:center; gap:.35rem; height:2.2rem; padding:0 .75rem; border:1px solid var(--sm-border); border-radius:.45rem; background:#fff; color:var(--sm-ink); font:inherit; font-weight:600; text-decoration:none; white-space:nowrap; cursor:pointer; } .sm-button .material-icons-outlined { font-size:1rem; } .sm-button--primary { border-color:var(--sm-accent); background:var(--sm-accent); color:#fff; } .sm-button--primary:hover { background:#6a5129; } .sm-button:disabled { cursor:not-allowed; opacity:.55; } .sm-table-wrap { max-height:min(62vh,42rem); overflow:auto; overscroll-behavior:contain; scrollbar-gutter:stable both-edges; } .sm-table { width:max-content; min-width:100%; border-collapse:separate; border-spacing:0; font-size:.75rem; } .sm-table th,.sm-table td { min-width:7.5rem; padding:.62rem .7rem; border-right:1px solid var(--sm-border); border-bottom:1px solid var(--sm-border); text-align:left; vertical-align:top; white-space:nowrap; } .sm-table th { position:sticky; top:0; z-index:2; background:var(--sm-soft); font-weight:700; } .sm-table [data-column-key="id"] { position:sticky; left:0; z-index:3; background:#fff; } .sm-table th[data-column-key="id"] { z-index:5; background:var(--sm-soft); } .sm-table [data-column-key="actions"] { position:sticky; right:0; z-index:3; background:#fff; box-shadow:-1px 0 0 var(--sm-border); } .sm-table th[data-column-key="actions"] { z-index:5; background:var(--sm-soft); } .sm-table tr:hover [data-column-key="id"],.sm-table tr:hover [data-column-key="actions"] { background:#fcfaf4; } .sm-table th[data-fixed="true"] { cursor:default; } .sm-table th[data-column-header]:not([data-fixed="true"]) { cursor:grab; } .sm-table th[data-column-header]:not([data-fixed="true"]):active { cursor:grabbing; } .sm-table th.is-dragging { opacity:.55; } .sm-table th.is-drop-target { box-shadow:inset 3px 0 0 var(--sm-accent); } .sm-table th .sm-table__header { display:inline-flex; align-items:center; gap:.35rem; } .sm-table th .sm-table__drag { color:var(--sm-muted); font-size:1rem; } .sm-table [data-column-key].is-column-hidden { display:none; } .sm-table tr:hover td { background:#fcfaf4; } .sm-table .wrap { max-width:18rem; white-space:normal; line-height:1.5; } .sm-table .actions { display:flex; gap:.35rem; } .sm-table .actions button { height:1.8rem; padding:0 .5rem; font-size:.7rem; } .sm-table-meta { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:.6rem 1rem; padding:.65rem 1.15rem; border-bottom:1px solid var(--sm-border); background:#fffdf8; color:var(--sm-muted); font-size:.72rem; } + .sm-audit-table-wrap { max-height:24rem; overflow:auto; overscroll-behavior:contain; scrollbar-gutter:stable both-edges; } + .sm-audit-table { width:max-content; min-width:100%; border-collapse:separate; border-spacing:0; font-size:.75rem; } + .sm-audit-table th,.sm-audit-table td { min-width:8rem; padding:.6rem .7rem; border-right:1px solid var(--sm-border); border-bottom:1px solid var(--sm-border); text-align:left; vertical-align:top; white-space:nowrap; } + .sm-audit-table th { background:var(--sm-soft); font-weight:700; } + .sm-audit-table td:nth-child(5) { min-width:24rem; white-space:normal; line-height:1.5; } + .sm-audit-action { display:inline-flex; align-items:center; padding:.15rem .45rem; border-radius:999px; background:var(--sm-soft); color:var(--sm-accent); font-size:.7rem; font-weight:700; } + .sm-audit-empty { padding:1.8rem 1rem; text-align:center; color:var(--sm-muted); } .sm-column-picker { position:relative; } .sm-column-picker__panel { position:absolute; right:0; z-index:20; display:grid; width:min(22rem,calc(100vw - 2rem)); gap:.65rem; margin-top:.35rem; padding:.8rem; border:1px solid var(--sm-border); border-radius:.55rem; background:#fff; box-shadow:0 4px 8px rgba(23,23,23,.1); } .sm-column-picker__panel[hidden] { display:none; } .sm-column-picker__intro { display:grid; gap:.2rem; color:var(--sm-muted); font-size:.7rem; line-height:1.45; } .sm-column-picker__intro strong { color:var(--sm-ink); font-size:.78rem; } .sm-visually-hidden { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; } .sm-column-picker__list { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:.35rem; max-height:15rem; overflow:auto; } .sm-column-picker__option { display:flex; align-items:center; gap:.35rem; min-width:0; padding:.35rem .4rem; border:1px solid transparent; border-radius:.35rem; color:var(--sm-ink); font-size:.7rem; cursor:grab; } .sm-column-picker__option:hover,.sm-column-picker__option.is-drop-target { border-color:var(--sm-border); background:#fffdf8; } .sm-column-picker__option.is-fixed { cursor:default; } .sm-column-picker__option.is-dragging { opacity:.55; } .sm-column-picker__option input { flex:none; accent-color:var(--sm-accent); } .sm-column-picker__option .material-icons-outlined { color:var(--sm-muted); font-size:.95rem; } .sm-column-picker__foot { display:flex; justify-content:flex-end; padding-top:.35rem; border-top:1px solid var(--sm-border); } .sm-badge { display:inline-flex; align-items:center; padding:.15rem .5rem; border-radius:999px; font-size:.7rem; font-weight:700; } .sm-badge.is-active { background:#edf5e8; color:#53793e; } .sm-badge.is-inactive { background:#fff4df; color:#935f07; } .sm-empty { padding:3rem 1rem; text-align:center; color:var(--sm-muted); } .sm-rule-note { padding:1rem 1.15rem; color:var(--sm-muted); font-size:.75rem; line-height:1.65; background:#fffdf8; } @@ -58,7 +68,7 @@ @media (max-width:620px) { .sm-summary { grid-template-columns:repeat(2,1fr); } .sm-summary__intro { grid-column:span 2; } .sm-metric { border-left:0; border-top:1px solid var(--sm-border); } .sm-toolbar,.sm-form,.sm-type-create { grid-template-columns:minmax(0,1fr); } .sm-form__types { grid-template-columns:repeat(2,minmax(0,1fr)); } .sm-actions,.sm-form__field--full,.sm-form__foot { grid-column:auto; } .sm-actions .sm-button { flex:1; } } -
{{ $ready ? '正式维护数据' : '数据表尚未就绪' }} {{ $ready ? '报价规则已保存到当前公司数据表,刷新页面后仍会保留;后续结算页面将按启用中的规则计算。' : '附加费报价数据表尚未完成迁移,当前不能保存规则。请由管理员在目标数据库完成迁移后再使用本页。' }}
+
{{ $ready ? '正式维护数据' : '数据表尚未就绪' }} {{ $ready ? '报价规则已保存到当前公司数据表,刷新页面后仍会保留;后续结算页面将按启用中的规则计算。' : ($dataReady && !$auditReady ? '操作日志表尚未完成迁移;现有报价仍可查看,新增、编辑和启停暂时禁用,避免产生无日志修改。' : '附加费报价数据表尚未完成迁移,当前不能保存规则。请由管理员在目标数据库完成迁移后再使用本页。') }}
快递附加费报价
统一维护各快递的附加费结算规则
@@ -110,6 +120,32 @@
维护建议:固定单价、按重量、按体积、按尺寸/最长边等方式直接维护标准;聚工单客服核实、取发出运费和按账单等方式只维护触发条件,具体金额由后续结算链路读取业务凭证。当前筛选结果可导出为 CSV;本页暂未启用批量导入,避免在未确认字段规则前覆盖维护数据。
+
+
操作日志
记录当前公司最近 50 条新增、更改、启用和停用操作
+ @if (!$auditReady) +
操作日志表尚未就绪,完成迁移后将从首次新操作开始记录。
+ @elseif ($auditLogs->isEmpty()) +
暂无操作日志。新增、编辑或切换报价规则/附加费类型状态后,记录会显示在这里。
+ @else +
+ + + + @foreach ($auditLogs as $log) + + + + + + + + @endforeach + +
操作时间操作人动作对象变更内容
{{ $log['occurred_at'] }}{{ $log['actor_name'] }}{{ $log['action_label'] }}{{ $log['target_type_label'] }} · {{ $log['target_label'] }}{{ $log['changes_label'] }}
+
+ @endif +
+