diff --git a/app/Console/Commands/ImportSupplierBookkeepingConfigs.php b/app/Console/Commands/ImportSupplierBookkeepingConfigs.php new file mode 100644 index 00000000..fd3a9088 --- /dev/null +++ b/app/Console/Commands/ImportSupplierBookkeepingConfigs.php @@ -0,0 +1,28 @@ +argument('file'); + $rows = $importer->read($path); + if ($this->option('dry-run')) { + $this->info('文件校验通过,共' . count($rows) . '条;未写入。'); + + return self::SUCCESS; + } + $result = $store->import($rows, '命令行导入', hash_file('sha256', $path)); + $this->info("导入完成:新增{$result['added']}条,更新{$result['updated']}条,未变化{$result['skipped']}条。"); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Business/SupplierBookkeepingConfigs.php b/app/Http/Controllers/Business/SupplierBookkeepingConfigs.php new file mode 100644 index 00000000..1c4fb37b --- /dev/null +++ b/app/Http/Controllers/Business/SupplierBookkeepingConfigs.php @@ -0,0 +1,100 @@ +store->save($this->validated($request), $this->actor()); + + return $this->back('厂家记账配置已新增,厂家记账明细按新配置重新判定。'); + } + + public function update(Request $request, int $configId) + { + $this->store->save($this->validated($request), $this->actor(), $configId); + + return $this->back('厂家记账配置已更新,厂家记账明细按新配置重新判定。'); + } + + public function destroy(int $configId) + { + $this->store->changeDeleted($configId, true, $this->actor()); + + return $this->back('配置已删除,该供应商的厂家记账将显示为“待配置”。'); + } + + public function restore(int $configId) + { + $this->store->changeDeleted($configId, false, $this->actor()); + + return $this->back('厂家记账配置已恢复。'); + } + + public function import(Request $request, SupplierBookkeepingConfigImporter $importer) + { + $request->validate(['file' => ['required', 'file', 'mimes:xlsx', 'max:10240']]); + $path = $request->file('file')->getRealPath(); + $result = $this->store->import($importer->read($path), $this->actor(), hash_file('sha256', $path)); + + return $this->back("导入完成:新增{$result['added']}条,更新{$result['updated']}条,未变化{$result['skipped']}条。"); + } + + public function export(SupplierBookkeepingConfigPageData $data) + { + $rows = $this->store->filtered($data->filters())->get(); + $book = new Spreadsheet(); + $sheet = $book->getActiveSheet(); + $sheet->setTitle('厂家记账配置'); + $sheet->fromArray([['供应商', '记账方式', '生效开始日期', '生效结束日期', '原始说明', '创建人', '更新时间', '删除状态']]); + foreach ($rows as $index => $row) { + $values = [$row->supplier_name, Rule::MODES[$row->mode] ?? $row->mode, $row->effective_from, $row->effective_to, $row->source_text, $row->creator, $row->updated_at, $row->deleted_at ? '已删除' : '正常']; + foreach ($values as $column => $value) { + $sheet->setCellValueExplicit([$column + 1, $index + 2], (string) $value, DataType::TYPE_STRING); + } + } + foreach (['A' => 24, 'B' => 20, 'C' => 14, 'D' => 14, 'E' => 44, 'F' => 16, 'G' => 20, 'H' => 10] as $column => $width) { + $sheet->getColumnDimension($column)->setWidth($width); + } + $sheet->freezePane('A2'); + + return response()->streamDownload(function () use ($book): void { + try { (new Xlsx($book))->save('php://output'); } finally { $book->disconnectWorksheets(); } + }, '厂家记账配置.xlsx', ['Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet']); + } + + private function validated(Request $request): array + { + return $request->validate([ + 'supplier_name' => ['required', 'string', 'max:191'], + 'mode' => ['required', ValidationRule::in(array_keys(Rule::MODES))], + 'effective_from' => ['nullable', 'date_format:Y-m-d'], + 'effective_to' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:effective_from'], + 'source_text' => ['nullable', 'string', 'max:255'], + ], [], ['supplier_name' => '供应商', 'mode' => '记账方式', 'effective_from' => '生效开始日期', 'effective_to' => '生效结束日期', 'source_text' => '备注']); + } + + private function actor(): string + { + return mb_substr((string) user()->name, 0, 191); + } + + private function back(string $message) + { + return redirect()->route('business.pages.show', ['section' => 'supplier-payables', 'page' => 'manufacturer-bookkeeping', 'bk_view' => 'config'])->with('bookkeeping_config_message', $message); + } +} diff --git a/database/migrations/2026_10_19_100000_create_supplier_bookkeeping_configs.php b/database/migrations/2026_10_19_100000_create_supplier_bookkeeping_configs.php new file mode 100644 index 00000000..89273bf2 --- /dev/null +++ b/database/migrations/2026_10_19_100000_create_supplier_bookkeeping_configs.php @@ -0,0 +1,66 @@ +increments('id'); + $table->string('supplier_name', 191)->unique('supplier_bookkeeping_config_supplier_unique'); + $table->string('mode', 32); + $table->date('effective_from')->nullable(); + $table->date('effective_to')->nullable(); + $table->string('source_text', 255)->default(''); + $table->string('creator', 191); + $table->timestamps(); + $table->softDeletes(); + }); + Schema::create('business_supplier_bookkeeping_config_events', function (Blueprint $table): void { + $table->increments('id'); + $table->unsignedInteger('config_id')->nullable()->index(); + $table->string('actor', 191); + $table->string('action', 32); + $table->text('description'); + $table->longText('details'); + $table->dateTime('created_at'); + }); + + $payload = json_decode((string) file_get_contents(resource_path('data/supplier-bookkeeping-configs.json')), true, 16, JSON_THROW_ON_ERROR); + $now = now(config('app.platform_timezone', 'Asia/Shanghai'))->format('Y-m-d H:i:s'); + $rows = array_map(static fn (array $row): array => [ + 'supplier_name' => $row['supplier_name'], + 'mode' => $row['mode'], + 'effective_from' => $row['effective_from'], + 'effective_to' => $row['effective_to'], + 'source_text' => $row['source_text'], + 'creator' => '存量配置导入', + 'created_at' => $now, + 'updated_at' => $now, + ], $payload['configs']); + foreach (array_chunk($rows, 100) as $chunk) { + DB::table('business_supplier_bookkeeping_configs')->insert($chunk); + } + DB::table('business_supplier_bookkeeping_config_events')->insert([ + 'config_id' => null, + 'actor' => '存量配置导入', + 'action' => 'import', + 'description' => '导入存量厂家记账配置:新增' . count($rows) . '条', + 'details' => json_encode(['added' => count($rows), 'source' => $payload['source']], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), + 'created_at' => $now, + ]); + } + + public function down(): void + { + Schema::dropIfExists('business_supplier_bookkeeping_config_events'); + Schema::dropIfExists('business_supplier_bookkeeping_configs'); + } +}; diff --git a/modules/EcommerceFinance/Services/SupplierBookkeepingConfigImporter.php b/modules/EcommerceFinance/Services/SupplierBookkeepingConfigImporter.php new file mode 100644 index 00000000..540a375f --- /dev/null +++ b/modules/EcommerceFinance/Services/SupplierBookkeepingConfigImporter.php @@ -0,0 +1,121 @@ + */ + public function read(string $path): array + { + $reader = IOFactory::createReader('Xlsx'); + $info = $reader->listWorksheetInfo($path); + if ($info === [] || $info[0]['totalRows'] > 5001 || $info[0]['totalColumns'] > 20) { + throw ValidationException::withMessages(['file' => '请使用第一个工作表存放配置,最多5000条。']); + } + $reader->setLoadSheetsOnly($info[0]['worksheetName']); + $book = $reader->load($path); + try { + $sheet = $book->getSheet(0); + $headers = []; + for ($column = 1; $column <= $info[0]['totalColumns']; $column++) { + $headers[$this->label($sheet->getCell([$column, 1])->getValue())] = $column; + } + $supplierColumn = $headers['供应商'] ?? $headers['供应商名称'] ?? null; + $ruleColumn = $headers['厂家是否记账'] ?? null; + $modeColumn = $headers['记账方式'] ?? null; + if ($supplierColumn === null || ($ruleColumn === null && $modeColumn === null)) { + throw ValidationException::withMessages(['file' => '缺少列:需要“供应商”和“厂家是否记账”(或“记账方式”)。']); + } + + $rows = []; + $seen = []; + for ($number = 2; $number <= $sheet->getHighestDataRow(); $number++) { + $supplier = $this->text($sheet, $supplierColumn, $number); + $ruleText = $ruleColumn !== null ? $this->text($sheet, $ruleColumn, $number) : ''; + $modeText = $modeColumn !== null ? $this->text($sheet, $modeColumn, $number) : ''; + // 原表中间重复出现的表头行、空行跳过。 + if (($supplier === '' && $ruleText === '' && $modeText === '') || $this->label($supplier) === '供应商') { + continue; + } + if ($supplier === '' || mb_strlen($supplier) > 191) { + throw ValidationException::withMessages(['file' => "第{$number}行供应商为空或超长。"]); + } + $key = mb_strtolower($supplier); + if (isset($seen[$key])) { + throw ValidationException::withMessages(['file' => "第{$number}行供应商“{$supplier}”与第{$seen[$key]}行重复。"]); + } + $seen[$key] = $number; + + try { + $parsed = Rule::parseRuleText($ruleText !== '' ? $ruleText : $modeText); + } catch (InvalidArgumentException $exception) { + throw ValidationException::withMessages(['file' => "第{$number}行:" . $exception->getMessage()]); + } + foreach (['effective_from' => '生效开始日期', 'effective_to' => '生效结束日期'] as $field => $label) { + if (isset($headers[$label])) { + $date = $this->date($sheet, $headers[$label], $number); + if ($date !== null) { + $parsed[$field] = $date; + } + } + } + if ($parsed['effective_from'] !== null && $parsed['effective_to'] !== null && $parsed['effective_from'] > $parsed['effective_to']) { + throw ValidationException::withMessages(['file' => "第{$number}行生效结束日期早于开始日期。"]); + } + $source = isset($headers['原始说明']) ? $this->text($sheet, $headers['原始说明'], $number) : ''; + $rows[] = ['supplier_name' => $supplier] + $parsed + ['source_text' => $ruleText !== '' ? $ruleText : ($source !== '' ? $source : $modeText)]; + } + if ($rows === []) { + throw ValidationException::withMessages(['file' => '配置表没有有效记录。']); + } + + return $rows; + } finally { + $book->disconnectWorksheets(); + } + } + + private function label(mixed $value): string + { + return str_replace([' ', ' ', "\n"], '', trim((string) $value)); + } + + private function text($sheet, int $column, int $row): string + { + $cell = $sheet->getCell([$column, $row]); + if ($cell->getDataType() === 'f') { + throw ValidationException::withMessages(['file' => "第{$row}行含公式,请使用实际配置值。"]); + } + + return trim((string) $cell->getFormattedValue()); + } + + private function date($sheet, int $column, int $row): ?string + { + $cell = $sheet->getCell([$column, $row]); + $value = $cell->getValue(); + if ($value === null || trim((string) $value) === '') { + return null; + } + if (is_numeric($value) && ExcelDate::isDateTime($cell)) { + return ExcelDate::excelToDateTimeObject($value)->format('Y-m-d'); + } + $text = str_replace(['/', '.'], '-', trim((string) $cell->getFormattedValue())); + if (! preg_match('/^(\d{4})-(\d{1,2})-(\d{1,2})$/', $text, $m) || ! checkdate((int) $m[2], (int) $m[3], (int) $m[1])) { + throw ValidationException::withMessages(['file' => "第{$row}行日期无效,请使用 2026-01-31 格式。"]); + } + + return sprintf('%04d-%02d-%02d', $m[1], $m[2], $m[3]); + } +} diff --git a/modules/EcommerceFinance/Services/SupplierBookkeepingConfigStore.php b/modules/EcommerceFinance/Services/SupplierBookkeepingConfigStore.php new file mode 100644 index 00000000..ec6e149c --- /dev/null +++ b/modules/EcommerceFinance/Services/SupplierBookkeepingConfigStore.php @@ -0,0 +1,156 @@ +> 供应商名称 => 生效中的配置(未删除) */ + public function activeConfigs(): array + { + $result = []; + foreach ($this->query()->whereNull('deleted_at')->get() as $row) { + $result[trim($row->supplier_name)] = (array) $row; + } + + return $result; + } + + public function filtered(array $filters) + { + $query = $this->query(); + $status = $filters['deleted_status'] ?? 'active'; + if ($status === 'deleted') { + $query->whereNotNull('deleted_at'); + } elseif ($status !== 'all') { + $query->whereNull('deleted_at'); + } + if (($filters['supplier_name'] ?? '') !== '') { + $query->where('supplier_name', 'like', '%' . addcslashes($filters['supplier_name'], '\\%_') . '%'); + } + if (($filters['mode'] ?? '') !== '') { + $query->where('mode', $filters['mode']); + } + + return $query->orderBy('supplier_name')->orderBy('id'); + } + + /** @param array{supplier_name:string,mode:string,effective_from:?string,effective_to:?string,source_text?:string} $data */ + public function save(array $data, string $actor, ?int $id = null): int + { + return DB::transaction(function () use ($data, $actor, $id): int { + $before = $id === null ? null : $this->query()->where('id', $id)->whereNull('deleted_at')->lockForUpdate()->first(); + abort_if($id !== null && $before === null, 404); + $name = trim($data['supplier_name']); + $duplicate = $this->query()->where('supplier_name', $name)->when($id !== null, fn ($q) => $q->where('id', '<>', $id))->exists(); + if ($duplicate) { + throw ValidationException::withMessages(['supplier_name' => '该供应商已有配置(含已删除),请编辑或恢复原配置。']); + } + $time = $this->time(); + $values = ['supplier_name' => $name, 'updated_at' => $time] + $this->values($data); + if ($id === null) { + $id = $this->query()->insertGetId($values + ['creator' => $actor, 'created_at' => $time]); + } else { + $this->query()->where('id', $id)->update($values); + } + $this->event($id, $actor, $before ? 'update' : 'create', ($before ? '修改' : '新增') . '厂家记账配置:' . $name . ' → ' . Rule::describe($values), ['before' => $before, 'after' => $values]); + + return $id; + }); + } + + public function changeDeleted(int $id, bool $deleted, string $actor): void + { + DB::transaction(function () use ($id, $deleted, $actor): void { + $row = $this->query()->where('id', $id)->lockForUpdate()->first(); + abort_if($row === null, 404); + if (($row->deleted_at !== null) === $deleted) { + return; + } + $this->query()->where('id', $id)->update(['deleted_at' => $deleted ? $this->time() : null, 'updated_at' => $this->time()]); + $this->event($id, $actor, $deleted ? 'delete' : 'restore', ($deleted ? '删除' : '恢复') . '厂家记账配置:' . $row->supplier_name, ['supplier_name' => $row->supplier_name, 'mode' => $row->mode]); + }); + } + + /** + * 整批导入:新供应商新增;已有供应商记账方式或生效期不同则按文件更新(已删除的一并恢复);相同则跳过。 + * + * @param list $rows + * @return array{added:int,updated:int,skipped:int} + */ + public function import(array $rows, string $actor, string $sourceHash): array + { + return DB::transaction(function () use ($rows, $actor, $sourceHash): array { + $added = $updated = $skipped = 0; + $time = $this->time(); + foreach ($rows as $row) { + $name = trim($row['supplier_name']); + $values = $this->values($row); + $existing = $this->query()->where('supplier_name', $name)->lockForUpdate()->first(); + if ($existing === null) { + $this->query()->insert(['supplier_name' => $name, 'creator' => $actor, 'created_at' => $time, 'updated_at' => $time] + $values); + $added++; + continue; + } + $same = $existing->deleted_at === null; + foreach (['mode', 'effective_from', 'effective_to'] as $field) { + $same = $same && (string) $existing->{$field} === (string) $values[$field]; + } + if ($same) { + $skipped++; + continue; + } + $this->query()->where('id', $existing->id)->update($values + ['deleted_at' => null, 'updated_at' => $time]); + $updated++; + } + $this->event(null, $actor, 'import', "导入厂家记账配置:新增{$added}条,更新{$updated}条,未变化{$skipped}条", compact('added', 'updated', 'skipped') + ['source_sha256' => $sourceHash]); + + return compact('added', 'updated', 'skipped'); + }); + } + + public function events() + { + return DB::table(self::EVENTS)->orderByDesc('id'); + } + + private function values(array $data): array + { + $values = []; + foreach (self::FIELDS as $field) { + $value = $data[$field] ?? null; + $values[$field] = $field === 'source_text' ? mb_substr(trim((string) $value), 0, 255) : (($value === '' || $value === null) ? null : (string) $value); + } + abort_unless(isset(Rule::MODES[$values['mode']]), 422, '记账方式无效。'); + if ($values['effective_from'] !== null && $values['effective_to'] !== null && $values['effective_from'] > $values['effective_to']) { + throw ValidationException::withMessages(['effective_to' => '生效结束日期不能早于开始日期。']); + } + + return $values; + } + + private function event(?int $id, string $actor, string $action, string $description, array $details): void + { + DB::table(self::EVENTS)->insert(['config_id' => $id, 'actor' => $actor, 'action' => $action, 'description' => mb_substr($description, 0, 1000), 'details' => json_encode($details, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), 'created_at' => $this->time()]); + } + + private function time(): string + { + return Carbon::now(config('app.platform_timezone', 'Asia/Shanghai'))->format('Y-m-d H:i:s'); + } +} diff --git a/modules/EcommerceFinance/Services/SupplierBookkeepingDeductionRule.php b/modules/EcommerceFinance/Services/SupplierBookkeepingDeductionRule.php new file mode 100644 index 00000000..8de1fb05 --- /dev/null +++ b/modules/EcommerceFinance/Services/SupplierBookkeepingDeductionRule.php @@ -0,0 +1,178 @@ + '正常记账', + self::MODE_DROPSHIP_WRONG => '仅代发仓发错货记账', + self::MODE_NONE => '不记账', + ]; + + public const STATUS_DEDUCT = '扣款'; + public const STATUS_SKIP = '不扣款'; + public const STATUS_UNCONFIGURED = '待配置'; + public const STATUS_REVIEW = '待确认'; + + public const STATUSES = [self::STATUS_DEDUCT, self::STATUS_SKIP, self::STATUS_UNCONFIGURED, self::STATUS_REVIEW]; + + public const DROPSHIP_WAREHOUSE_KEYWORD = '代发仓'; + public const WAREHOUSE_WRONG_KEYWORD = '仓库发错货'; + + /** + * 解析配置表中“厂家是否记账”原文,例如“代发仓发货错需记账,其他原因均不记账”“不记账(2025.3月-2027.12.30)”。 + * + * @return array{mode:string,effective_from:?string,effective_to:?string} + */ + public static function parseRuleText(string $text): array + { + $normalized = str_replace(['(', ')', ' ', ' '], ['(', ')', '', ''], trim($text)); + if ($normalized === '') { + throw new InvalidArgumentException('厂家是否记账为空。'); + } + $label = array_search($normalized, self::MODES, true); + if (is_string($label)) { + return ['mode' => $label, 'effective_from' => null, 'effective_to' => null]; + } + if (str_contains($normalized, '代发仓') && (str_contains($normalized, '发货错') || str_contains($normalized, '发错货'))) { + $mode = self::MODE_DROPSHIP_WRONG; + } elseif (str_starts_with($normalized, '正常记账')) { + $mode = self::MODE_NORMAL; + } elseif (str_starts_with($normalized, '不记账')) { + $mode = self::MODE_NONE; + } else { + throw new InvalidArgumentException('无法识别的记账方式:' . $text); + } + + [$from, $to] = [null, null]; + $date = '(\d{4})[.\/年](\d{1,2})月?(?:[.\/](\d{1,2})日?)?'; + if (preg_match('/(' . $date . '[-—~至]+' . $date . ')/u', $normalized, $m)) { + $from = self::date((int) $m[1], (int) $m[2], ($m[3] ?? '') === '' ? 1 : (int) $m[3]); + $to = self::date((int) $m[4], (int) $m[5], ($m[6] ?? '') === '' ? null : (int) $m[6]); + if ($from > $to) { + throw new InvalidArgumentException('生效期开始晚于结束:' . $text); + } + } elseif (str_contains($normalized, '(')) { + throw new InvalidArgumentException('无法识别的生效期:' . $text); + } + + return ['mode' => $mode, 'effective_from' => $from, 'effective_to' => $to]; + } + + /** 配置的展示文字,例如“不记账(2025-03-01 至 2026-02-28)”。 */ + public static function describe(array|object $config): string + { + $config = (array) $config; + $label = self::MODES[$config['mode']] ?? (string) $config['mode']; + $from = $config['effective_from'] ?? null; + $to = $config['effective_to'] ?? null; + if ($from === null && $to === null) { + return $label; + } + + return $label . '(' . ($from ?? '不限') . ' 至 ' . ($to ?? '不限') . ')'; + } + + /** + * @param array $row 厂家记账明细行 + * @param array|object> $configs 供应商名称 => 生效中的配置 + * @return array{supplier_config:string,deduction_status:string,deduction_basis:string} + */ + public static function decide(array $row, array $configs): array + { + $level1 = (string) ($row['reason_level_1'] ?? ''); + $level2 = (string) ($row['reason_level_2'] ?? ''); + $date = substr((string) ($row['started_at'] ?? ''), 0, 10); + $suppliers = array_values(array_unique(array_filter(array_map('trim', explode("\n", (string) ($row['supplier'] ?? '')))), SORT_STRING)); + + $found = []; + foreach ($suppliers as $supplier) { + $found[$supplier] = isset($configs[$supplier]) ? (array) $configs[$supplier] : null; + } + $configText = implode("\n", array_map(static fn (?array $config): string => $config === null ? '未配置' : self::describe($config), $found)); + + if ($level1 === '仓库记账') { + $warehouse = (string) ($row['shipping_warehouse'] ?? ''); + if (! str_contains($warehouse, self::DROPSHIP_WAREHOUSE_KEYWORD) || ! str_contains($level2, self::WAREHOUSE_WRONG_KEYWORD)) { + return self::result($configText, self::STATUS_SKIP, '仓库记账仅“代发仓 + 仓库发错货”作为厂家扣款,本单不扣'); + } + } elseif ($level1 !== '厂家记账') { + return self::result($configText, self::STATUS_SKIP, '非厂家记账/仓库记账原因'); + } + + if ($suppliers === []) { + return self::result('', self::STATUS_UNCONFIGURED, '工单未带供应商,无法匹配厂家记账配置'); + } + + $outcomes = []; + foreach ($found as $supplier => $config) { + $outcomes[$supplier] = self::outcome($level1, $config, $date); + } + $statuses = array_values(array_unique(array_column($outcomes, 0))); + if (count($statuses) > 1) { + $detail = implode(';', array_map(static fn (string $supplier, array $o): string => $supplier . ':' . $o[0], array_keys($outcomes), $outcomes)); + + return self::result($configText, self::STATUS_REVIEW, '多个供应商判定不一致,请人工确认(' . $detail . ')'); + } + + return self::result($configText, $statuses[0], reset($outcomes)[1]); + } + + /** @return array{0:string,1:string} [判定, 说明] */ + private static function outcome(string $level1, ?array $config, string $date): array + { + if ($config === null) { + return [self::STATUS_UNCONFIGURED, '供应商未配置厂家记账方式']; + } + $from = $config['effective_from'] ?? null; + $to = $config['effective_to'] ?? null; + if (($from !== null && $date < $from) || ($to !== null && $date > $to)) { + return [self::STATUS_UNCONFIGURED, '发起日期不在配置生效期内(' . self::describe($config) . '),请补充配置']; + } + + return match ($config['mode']) { + self::MODE_NONE => [self::STATUS_SKIP, '供应商配置为不记账'], + self::MODE_NORMAL => $level1 === '厂家记账' + ? [self::STATUS_DEDUCT, '供应商正常记账'] + : [self::STATUS_DEDUCT, '代发仓发错货,记厂家扣款'], + self::MODE_DROPSHIP_WRONG => $level1 === '仓库记账' + ? [self::STATUS_DEDUCT, '代发仓发错货,记厂家扣款'] + : [self::STATUS_SKIP, '供应商仅代发仓发错货记账,其他原因不记账'], + default => [self::STATUS_UNCONFIGURED, '无法识别的记账方式'], + }; + } + + private static function result(string $config, string $status, string $basis): array + { + return ['supplier_config' => $config, 'deduction_status' => $status, 'deduction_basis' => $basis]; + } + + private static function date(int $year, int $month, ?int $day): string + { + if ($month < 1 || $month > 12) { + throw new InvalidArgumentException('生效期月份无效。'); + } + $last = (int) date('t', mktime(0, 0, 0, $month, 1, $year)); + $day ??= $last; + if ($day < 1 || $day > $last) { + throw new InvalidArgumentException('生效期日期无效。'); + } + + return sprintf('%04d-%02d-%02d', $year, $month, $day); + } +} diff --git a/modules/EcommerceFinance/View/SupplierBookkeepingConfigPageData.php b/modules/EcommerceFinance/View/SupplierBookkeepingConfigPageData.php new file mode 100644 index 00000000..6644b11b --- /dev/null +++ b/modules/EcommerceFinance/View/SupplierBookkeepingConfigPageData.php @@ -0,0 +1,38 @@ + mb_substr(trim((string) request()->query($key, '')), 0, 191); + $filters = ['supplier_name' => $text('supplier_name'), 'mode' => $text('mode'), 'deleted_status' => $text('deleted_status') ?: 'active']; + abort_unless($filters['mode'] === '' || isset(Rule::MODES[$filters['mode']]), 422, '记账方式无效。'); + abort_unless(in_array($filters['deleted_status'], ['active', 'deleted', 'all'], true), 422, '删除状态无效。'); + + return $filters; + } + + public function page(): array + { + $filters = $this->filters(); + $counts = $this->store->query()->whereNull('deleted_at')->selectRaw('mode, COUNT(*) AS total')->groupBy('mode')->pluck('total', 'mode')->all(); + + return [ + 'view' => 'business.pages.supplier-payables.manufacturer-bookkeeping', + 'bookkeepingView' => 'config', + 'configFilters' => $filters, + 'configCounts' => $counts, + 'configRows' => $this->store->filtered($filters)->paginate(50, ['*'], 'page')->withQueryString(), + 'configEvents' => $this->store->events()->paginate(20, ['*'], 'event_page')->withQueryString(), + ]; + } +} diff --git a/modules/EcommerceFinance/View/SupplierBookkeepingPageData.php b/modules/EcommerceFinance/View/SupplierBookkeepingPageData.php index 5467451d..675cc069 100644 --- a/modules/EcommerceFinance/View/SupplierBookkeepingPageData.php +++ b/modules/EcommerceFinance/View/SupplierBookkeepingPageData.php @@ -4,6 +4,8 @@ namespace Modules\EcommerceFinance\View; use Illuminate\Pagination\LengthAwarePaginator; use Modules\EcommerceFinance\Repositories\SupplierBookkeepingWorkOrderRepository as Repository; +use Modules\EcommerceFinance\Services\SupplierBookkeepingConfigStore; +use Modules\EcommerceFinance\Services\SupplierBookkeepingDeductionRule as Rule; /** 供应商应付 → 厂家记账(聚工单“记账”工单镜像)。 */ class SupplierBookkeepingPageData @@ -25,6 +27,7 @@ class SupplierBookkeepingPageData 'started_at' => '发起时间', 'work_order_no' => '工单编号', 'initiator' => '发起人', 'bookkeeping_reason' => '记账原因', 'reason_level_1' => '记账原因-一级选项', 'reason_level_2' => '记账原因-二级选项', 'bookkeeping_type' => '记账类型', 'executor' => '执行人', 'source' => '记账来源', 'amount' => '记账金额', + 'deduction_status' => '厂家扣款判定', 'supplier_config' => '厂家记账配置', 'deduction_basis' => '判定说明', 'remark' => '记账备注说明', 'attachment' => '上传附件', 'online_order_no' => '线上订单号', 'internal_order_no' => '内部单号', 'actual_paid_amount' => '实付金额', 'carrier' => '物流公司', 'tracking_no' => '物流单号', 'shop_name' => '店铺名称', 'platform' => '平台', 'shipping_warehouse' => '订单发货仓库', @@ -39,7 +42,10 @@ class SupplierBookkeepingPageData public const MONEY_COLUMNS = ['amount', 'actual_paid_amount']; - public function __construct(private readonly Repository $repository) {} + public function __construct( + private readonly Repository $repository, + private readonly SupplierBookkeepingConfigStore $configs, + ) {} /** @return array */ public function filters(): array @@ -56,6 +62,9 @@ class SupplierBookkeepingPageData $status = request()->query('status', ''); abort_unless(is_string($status) && ($status === '' || in_array($status, Repository::STATUS_LABELS, true)), 422, '工单状态无效。'); $filters['status'] = $status; + $deduction = request()->query('deduction', ''); + abort_unless(is_string($deduction) && ($deduction === '' || in_array($deduction, Rule::STATUSES, true)), 422, '扣款判定无效。'); + $filters['deduction'] = $deduction; foreach (['bookkeeping_type' => 50, ...array_fill_keys(array_keys(self::TEXT_FILTERS), 200)] as $key => $max) { $value = request()->query($key, ''); abort_unless(is_string($value) && mb_strlen($value) <= $max, 422, '筛选条件格式无效。'); @@ -88,12 +97,17 @@ class SupplierBookkeepingPageData { $from = $filters['started_from'] !== '' ? $filters['started_from'] . ' 00:00:00' : ''; $to = $filters['started_to'] !== '' ? $filters['started_to'] . ' 23:59:59' : ''; + $deduction = $filters['deduction'] ?? ''; + $configs = $this->configs->activeConfigs(); foreach ($rows ?? $this->repository->rows() as $row) { + // 配置随时可改,判定不进镜像结果缓存,每次按当前配置计算。 + $row = Rule::decide($row, $configs) + $row; if ($from !== '' && $row['started_at'] < $from) continue; if ($to !== '' && $row['started_at'] > $to) continue; if ($filters['reason'] !== '' && $row['bookkeeping_reason'] !== $filters['reason'] && $row['reason_level_1'] !== $filters['reason']) continue; if ($filters['status'] !== '' && $row['status'] !== $filters['status']) continue; if ($filters['bookkeeping_type'] !== '' && $row['bookkeeping_type'] !== $filters['bookkeeping_type']) continue; + if ($deduction !== '' && $row['deduction_status'] !== $deduction) continue; foreach (self::TEXT_FILTERS as $key => $label) { if ($filters[$key] !== '' && mb_stripos((string) ($row[$key] ?? ''), $filters[$key]) === false) continue 2; } @@ -113,10 +127,13 @@ class SupplierBookkeepingPageData public function page(): array { + if (request()->query('bk_view') === 'config') { + return app(SupplierBookkeepingConfigPageData::class)->page(); + } $filters = $this->filters(); $page = max(1, (int) request('page', 1)); $sync = $this->repository->syncStatus(); - $summary = ['count' => 0, 'amount_cents' => 0, 'supplier_reason' => 0, 'warehouse_reason' => 0]; + $summary = ['count' => 0, 'amount_cents' => 0, 'supplier_reason' => 0, 'warehouse_reason' => 0, 'deduction_count' => 0, 'deduction_cents' => 0, 'unconfigured' => 0, 'review' => 0]; $items = []; $types = []; $error = null; @@ -136,6 +153,14 @@ class SupplierBookkeepingPageData $summary['count']++; $summary['amount_cents'] += self::cents($row['amount']); $summary[$row['reason_level_1'] === '厂家记账' ? 'supplier_reason' : 'warehouse_reason']++; + if ($row['deduction_status'] === Rule::STATUS_DEDUCT) { + $summary['deduction_count']++; + $summary['deduction_cents'] += self::cents($row['amount']); + } elseif ($row['deduction_status'] === Rule::STATUS_UNCONFIGURED) { + $summary['unconfigured']++; + } elseif ($row['deduction_status'] === Rule::STATUS_REVIEW) { + $summary['review']++; + } } } catch (\Throwable $exception) { report($exception); @@ -148,6 +173,7 @@ class SupplierBookkeepingPageData return [ 'view' => 'business.pages.supplier-payables.manufacturer-bookkeeping', + 'bookkeepingView' => 'details', 'bookkeepingFilters' => $filters, 'bookkeepingSummary' => $summary, 'bookkeepingSync' => $sync, diff --git a/resources/data/supplier-bookkeeping-configs.json b/resources/data/supplier-bookkeeping-configs.json new file mode 100644 index 00000000..4079d4a1 --- /dev/null +++ b/resources/data/supplier-bookkeeping-configs.json @@ -0,0 +1,1468 @@ +{ + "source": "厂家记账配置.xlsx(2026-10-19 财务提供,209 家供应商)", + "configs": [ + { + "supplier_name": "尊享", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "班布", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "卓升", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "张定办公", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "裕宏", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "星杰", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "顺奇", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "顺达", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "俊佳", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "简尚", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "德诺", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "昌辉", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "安徽绩优", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "东莞宜家", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "淳安青节", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北廊坊淘品家居", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "浙江远盼", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "浙江有慧", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "浙江永鑫", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "浙江亿龙", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "浙江容心", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "浙江乐享", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "浙江乐活", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "浙江贵竹", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "浙江枫悦", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "浙江代发", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "浙江骋怀", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "浙江餐餐乐", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "浙江贝奇", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "永康中诺", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "永创", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "映辉工艺品厂", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "宿迁博创", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "小百灵", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "翔飞达", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "同德电器", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "腾达", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "山东鑫海", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "山东文飞", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "山东瑞和", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "山东绿然", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "山东精尚", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "山东汇源", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "山东华雕", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "山东宏阳", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "山东恒大", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "山东亨泰家居", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "山东超强", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "丘比特", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "木之风纸业", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "蓝鲸贸易(浙江代发)", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "科美", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "锦乐", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "江西墨腾", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "江西李布艺", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "江西福荣康", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "恒禹", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北苑氏", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北源龙", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北永兴", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北永盛", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北永超", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北鑫焱", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北欣硕", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北祥云", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北天才", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北胜芳俊生", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北赛威乖乖虎", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北瑞通", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北巧森", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北欧佳亿", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北诺伊尔", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北美宇轩", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北龙达", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北领鑫", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北琳伟", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北俊生", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北君发", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北久翔", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北江浩", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北袆濛", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北宏发", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北高桥", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北聪贝乐", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北驰旭", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北宝英", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "广东拓轩", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "广东吉邦", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "冠创", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "富华", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "福州中本", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "菲美", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "创乐", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "曹县鑫正木业", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "霸州祥华", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "安吉佳旺", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "安徽木之风", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "安得力椅业", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北君德轩", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "宿迁金彩家具", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "杭州国丽", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "鑫玉景家具", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "永康依友", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北恒奥特", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北邦隆家具", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北优派", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "鑫福轩", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "曹县中欧", + "mode": "dropship_wrong", + "effective_from": null, + "effective_to": null, + "source_text": "厂家暂未出货,后期若建立代发仓,发货错需记账,其他原因均不记账" + }, + { + "supplier_name": "曹县一川", + "mode": "dropship_wrong", + "effective_from": null, + "effective_to": null, + "source_text": "厂家暂未出货,后期若建立代发仓,发货错需记账,其他原因均不记账" + }, + { + "supplier_name": "曹县洪耀", + "mode": "dropship_wrong", + "effective_from": null, + "effective_to": null, + "source_text": "代发仓发货错需记账,其他原因均不记账" + }, + { + "supplier_name": "临沂尚然家居", + "mode": "dropship_wrong", + "effective_from": null, + "effective_to": null, + "source_text": "代发仓发货错需记账,其他原因均不记账" + }, + { + "supplier_name": "临沂坤宇", + "mode": "dropship_wrong", + "effective_from": null, + "effective_to": null, + "source_text": "代发仓发货错需记账,其他原因均不记账" + }, + { + "supplier_name": "江西翡翠明珠", + "mode": "dropship_wrong", + "effective_from": null, + "effective_to": null, + "source_text": "代发仓发货错需记账,其他原因均不记账" + }, + { + "supplier_name": "河北安美", + "mode": "dropship_wrong", + "effective_from": null, + "effective_to": null, + "source_text": "代发仓发货错需记账,其他原因均不记账" + }, + { + "supplier_name": "福州帕克思", + "mode": "dropship_wrong", + "effective_from": null, + "effective_to": null, + "source_text": "代发仓发货错需记账,其他原因均不记账" + }, + { + "supplier_name": "曹县腾辉", + "mode": "dropship_wrong", + "effective_from": null, + "effective_to": null, + "source_text": "代发仓发货错需记账,其他原因均不记账" + }, + { + "supplier_name": "曹县华业", + "mode": "dropship_wrong", + "effective_from": null, + "effective_to": null, + "source_text": "代发仓发货错需记账,其他原因均不记账" + }, + { + "supplier_name": "曹县汉信", + "mode": "dropship_wrong", + "effective_from": null, + "effective_to": null, + "source_text": "代发仓发货错需记账,其他原因均不记账" + }, + { + "supplier_name": "曹县大印", + "mode": "dropship_wrong", + "effective_from": null, + "effective_to": null, + "source_text": "代发仓发货错需记账,其他原因均不记账" + }, + { + "supplier_name": "山东瑞敏", + "mode": "dropship_wrong", + "effective_from": null, + "effective_to": null, + "source_text": "代发仓发货错需记账,其他原因均不记账" + }, + { + "supplier_name": "河北邦万元", + "mode": "dropship_wrong", + "effective_from": null, + "effective_to": null, + "source_text": "代发仓发货错需记账,其他原因均不记账" + }, + { + "supplier_name": "菏泽发展木业", + "mode": "dropship_wrong", + "effective_from": null, + "effective_to": null, + "source_text": "代发仓发货错需记账,其他原因均不记账" + }, + { + "supplier_name": "定陶业茂", + "mode": "dropship_wrong", + "effective_from": null, + "effective_to": null, + "source_text": "代发仓发货错需记账,其他原因均不记账" + }, + { + "supplier_name": "朔玛贸易", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北四强", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北璟成", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北银牛", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "霸州年源艺", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北宝生", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "广利竹木制品厂", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "四月萱", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北煎茶铺", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北宏派", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "霸州叠启", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北丽彪", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "山东超翔", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北伟圆", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北芬胜", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北恒泰", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北品硕", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "霸州鏖战", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "霸州国启", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "霸州柯帆", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "霸州华年", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北浩阔", + "mode": "dropship_wrong", + "effective_from": null, + "effective_to": null, + "source_text": "代发仓发货错需记账,其他原因均不记账" + }, + { + "supplier_name": "河北鑫福轩", + "mode": "dropship_wrong", + "effective_from": null, + "effective_to": null, + "source_text": "代发仓发货错需记账,其他原因均不记账" + }, + { + "supplier_name": "广东海鲨", + "mode": "dropship_wrong", + "effective_from": null, + "effective_to": null, + "source_text": "代发仓发货错需记账,其他原因均不记账" + }, + { + "supplier_name": "广州聚隆", + "mode": "dropship_wrong", + "effective_from": null, + "effective_to": null, + "source_text": "代发仓发货错需记账,其他原因均不记账" + }, + { + "supplier_name": "河北路超", + "mode": "dropship_wrong", + "effective_from": null, + "effective_to": null, + "source_text": "代发仓发货错需记账,其他原因均不记账" + }, + { + "supplier_name": "河北冬伟", + "mode": "dropship_wrong", + "effective_from": null, + "effective_to": null, + "source_text": "代发仓发货错需记账,其他原因均不记账" + }, + { + "supplier_name": "临沂富博", + "mode": "dropship_wrong", + "effective_from": null, + "effective_to": null, + "source_text": "代发仓发货错需记账,其他原因均不记账" + }, + { + "supplier_name": "武义萱宸", + "mode": "dropship_wrong", + "effective_from": null, + "effective_to": null, + "source_text": "代发仓发货错需记账,其他原因均不记账" + }, + { + "supplier_name": "杭州凯顺", + "mode": "dropship_wrong", + "effective_from": null, + "effective_to": null, + "source_text": "代发仓发货错需记账,其他原因均不记账" + }, + { + "supplier_name": "广东诺尔", + "mode": "dropship_wrong", + "effective_from": null, + "effective_to": null, + "source_text": "代发仓发货错需记账,其他原因均不记账" + }, + { + "supplier_name": "祥曼琦", + "mode": "dropship_wrong", + "effective_from": null, + "effective_to": null, + "source_text": "代发仓发货错需记账,其他原因均不记账" + }, + { + "supplier_name": "鑫呈", + "mode": "normal", + "effective_from": null, + "effective_to": null, + "source_text": "正常记账" + }, + { + "supplier_name": "安吉沃峰", + "mode": "normal", + "effective_from": null, + "effective_to": null, + "source_text": "正常记账" + }, + { + "supplier_name": "晨晔", + "mode": "normal", + "effective_from": null, + "effective_to": null, + "source_text": "正常记账" + }, + { + "supplier_name": "孝峰七星", + "mode": "normal", + "effective_from": null, + "effective_to": null, + "source_text": "正常记账" + }, + { + "supplier_name": "河北黑弟", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北宏铮", + "mode": "dropship_wrong", + "effective_from": null, + "effective_to": null, + "source_text": "代发仓发货错需记账,其他原因均不记账" + }, + { + "supplier_name": "河北七兴", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北志松", + "mode": "dropship_wrong", + "effective_from": null, + "effective_to": null, + "source_text": "代发仓发货错需记账,其他原因均不记账" + }, + { + "supplier_name": "柏美森", + "mode": "dropship_wrong", + "effective_from": null, + "effective_to": null, + "source_text": "代发仓发货错需记账,其他原因均不记账" + }, + { + "supplier_name": "乾语坤成", + "mode": "dropship_wrong", + "effective_from": null, + "effective_to": null, + "source_text": "代发仓发货错需记账,其他原因均不记账" + }, + { + "supplier_name": "曹县嘉欢", + "mode": "dropship_wrong", + "effective_from": null, + "effective_to": null, + "source_text": "代发仓发货错需记账,其他原因均不记账" + }, + { + "supplier_name": "三宝家具", + "mode": "dropship_wrong", + "effective_from": null, + "effective_to": null, + "source_text": "代发仓发货错需记账,其他原因均不记账" + }, + { + "supplier_name": "河北文良", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "鑫福通", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北炫彩", + "mode": "dropship_wrong", + "effective_from": null, + "effective_to": null, + "source_text": "代发仓发货错需记账,其他原因均不记账" + }, + { + "supplier_name": "菲尼科斯", + "mode": "dropship_wrong", + "effective_from": null, + "effective_to": null, + "source_text": "代发仓发货错需记账,其他原因均不记账" + }, + { + "supplier_name": "雅梵家具", + "mode": "dropship_wrong", + "effective_from": null, + "effective_to": null, + "source_text": "代发仓发货错需记账,其他原因均不记账" + }, + { + "supplier_name": "河北景轩", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北朝辉", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北达发", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "永康朗登机械", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "曹县宜美", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北浩爽", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北华兴", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北舒居客", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "菏泽顺杰", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "临沂满厚", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "临沂永鑫木制品厂", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "山东霖峰", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "泗县照轩", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "竹居士", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北沐泽睿", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北世博", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "江西博森", + "mode": "dropship_wrong", + "effective_from": null, + "effective_to": null, + "source_text": "代发仓发货错需记账,其他原因均不记账" + }, + { + "supplier_name": "河南一诺", + "mode": "dropship_wrong", + "effective_from": null, + "effective_to": null, + "source_text": "代发仓发货错需记账,其他原因均不记账" + }, + { + "supplier_name": "曹县玥涵", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "河北广易", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "海聚木业", + "mode": "dropship_wrong", + "effective_from": null, + "effective_to": null, + "source_text": "代发仓发货错需记账,其他原因均不记账" + }, + { + "supplier_name": "河北煜坤", + "mode": "dropship_wrong", + "effective_from": null, + "effective_to": null, + "source_text": "代发仓发货错需记账,其他原因均不记账" + }, + { + "supplier_name": "北京巧森", + "mode": "dropship_wrong", + "effective_from": null, + "effective_to": null, + "source_text": "代发仓发货错需记账,其他原因均不记账" + }, + { + "supplier_name": "鼎辉家具", + "mode": "dropship_wrong", + "effective_from": null, + "effective_to": null, + "source_text": "代发仓发货错需记账,其他原因均不记账" + }, + { + "supplier_name": "台州塔蒙家具", + "mode": "dropship_wrong", + "effective_from": null, + "effective_to": null, + "source_text": "代发仓发货错需记账,其他原因均不记账" + }, + { + "supplier_name": "霸州鼎盛家具", + "mode": "dropship_wrong", + "effective_from": null, + "effective_to": null, + "source_text": "代发仓发货错需记账,其他原因均不记账" + }, + { + "supplier_name": "霸州铜宇家具", + "mode": "dropship_wrong", + "effective_from": null, + "effective_to": null, + "source_text": "代发仓发货错需记账,其他原因均不记账" + }, + { + "supplier_name": "霸州筑浮家具", + "mode": "dropship_wrong", + "effective_from": null, + "effective_to": null, + "source_text": "代发仓发货错需记账,其他原因均不记账" + }, + { + "supplier_name": "郯城博盈木业", + "mode": "dropship_wrong", + "effective_from": null, + "effective_to": null, + "source_text": "代发仓发货错需记账,其他原因均不记账" + }, + { + "supplier_name": "霸州硕尔家居", + "mode": "dropship_wrong", + "effective_from": null, + "effective_to": null, + "source_text": "代发仓发货错需记账,其他原因均不记账" + }, + { + "supplier_name": "霸州骏峰", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "霸州金静灿", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "霸州越泽", + "mode": "none", + "effective_from": null, + "effective_to": null, + "source_text": "不记账" + }, + { + "supplier_name": "景瓴十方", + "mode": "none", + "effective_from": "2025-03-01", + "effective_to": "2026-02-28", + "source_text": "不记账(2025.3月-2026.2月)" + }, + { + "supplier_name": "金原工艺品", + "mode": "none", + "effective_from": "2025-03-01", + "effective_to": "2027-12-30", + "source_text": "不记账(2025.3月-2027.12.30)" + }, + { + "supplier_name": "乐佳金属", + "mode": "none", + "effective_from": "2025-03-01", + "effective_to": "2027-12-30", + "source_text": "不记账(2025.3月-2027.12.30)" + }, + { + "supplier_name": "江门纳捷", + "mode": "none", + "effective_from": "2025-03-01", + "effective_to": "2027-12-30", + "source_text": "不记账(2025.3月-2027.12.30)" + } + ] +} diff --git a/resources/views/business/pages/supplier-payables/manufacturer-bookkeeping.blade.php b/resources/views/business/pages/supplier-payables/manufacturer-bookkeeping.blade.php index fe34628e..a1c2c128 100644 --- a/resources/views/business/pages/supplier-payables/manufacturer-bookkeeping.blade.php +++ b/resources/views/business/pages/supplier-payables/manufacturer-bookkeeping.blade.php @@ -1,9 +1,20 @@ @php use Modules\EcommerceFinance\Repositories\SupplierBookkeepingWorkOrderRepository as BookkeepingRepository; use Modules\EcommerceFinance\View\SupplierBookkeepingPageData as BookkeepingPage; + use Modules\EcommerceFinance\Services\SupplierBookkeepingDeductionRule as DeductionRule; + $bookkeepingView = $bookkeepingView ?? 'details'; $columns = BookkeepingPage::COLUMNS; + $deductionClasses = [DeductionRule::STATUS_DEDUCT => 'is-deduct', DeductionRule::STATUS_SKIP => 'is-skip', DeductionRule::STATUS_UNCONFIGURED => 'is-pending', DeductionRule::STATUS_REVIEW => 'is-pending']; $syncLabels = ['ready' => '镜像已同步', 'failed' => '最近一次同步失败,展示上次成功数据', 'initializing' => '首次同步中', 'initial_failed' => '首次同步失败', 'unavailable' => '镜像未建立']; @endphp +
+ +@if($bookkeepingView === 'config') + @include('business.pages.supplier-payables.partials.manufacturer-bookkeeping-configs') +@else

聚水潭聚工单「记账」工单中,记账原因属于厂家记账、仓库记账的明细,发起时间自 2026-09-01 起。

@@ -22,6 +33,7 @@ @endforeach + @foreach(BookkeepingPage::TEXT_FILTERS as $key => $label) @@ -36,8 +48,11 @@
厂家记账{{ number_format($bookkeepingSummary['supplier_reason']) }}
仓库记账{{ number_format($bookkeepingSummary['warehouse_reason']) }}
记账金额合计(元){{ number_format($bookkeepingSummary['amount_cents'] / 100, 2) }}
+
厂家扣款工单{{ number_format($bookkeepingSummary['deduction_count']) }}
+
厂家扣款金额(元){{ number_format($bookkeepingSummary['deduction_cents'] / 100, 2) }}
+
待配置 / 待确认{{ number_format($bookkeepingSummary['unconfigured']) }} / {{ number_format($bookkeepingSummary['review']) }}
-
接入口径

与聚工单「协同工单列表 → 记账」视图一致:工单类型为“记账”;工单状态为待审核、已拒绝、待处理、处理中、已完成(不含已取消);记账原因为“厂家记账”下 12 项与“仓库记账”下 5 项二级原因;发起时间不早于 2026-09-01。同一工单号只取最新一版,状态或原因在聚工单变更后随镜像同步自动更新;多件商品的工单在商品列逐行展示,记账金额只计一次。数据只读,不在此修改。

+
接入口径

与聚工单「协同工单列表 → 记账」视图一致:工单类型为“记账”;工单状态为待审核、已拒绝、待处理、处理中、已完成(不含已取消);记账原因为“厂家记账”下 12 项与“仓库记账”下 5 项二级原因;发起时间不早于 2026-09-01。同一工单号只取最新一版,状态或原因在聚工单变更后随镜像同步自动更新;多件商品的工单在商品列逐行展示,记账金额只计一次。数据只读,不在此修改。

厂家扣款判定:记账原因为“厂家记账”的,按「厂家记账配置」中供应商的记账方式判定,仅“正常记账”计入扣款;记账原因为“仓库记账”的,只有订单发货仓库为“某某代发仓”且二级原因为“仓库发错货”时才作为厂家扣款项(供应商配置为“不记账”的除外),其余仓库记账一律不扣。供应商未配置或发起日期不在配置生效期内的显示“待配置”,多件商品分属不同供应商且判定不一致的显示“待确认”,均不计入扣款金额。

@if($bookkeepingError)@endif
@@ -51,6 +66,8 @@ @elseif($key === 'status') + @elseif($key === 'deduction_status') + @else @endif @@ -62,14 +79,21 @@ +@endif + @push('body_end') @endpush diff --git a/resources/views/business/pages/supplier-payables/partials/manufacturer-bookkeeping-configs.blade.php b/resources/views/business/pages/supplier-payables/partials/manufacturer-bookkeeping-configs.blade.php new file mode 100644 index 00000000..16017c5b --- /dev/null +++ b/resources/views/business/pages/supplier-payables/partials/manufacturer-bookkeeping-configs.blade.php @@ -0,0 +1,95 @@ +@php + use Modules\EcommerceFinance\Services\SupplierBookkeepingDeductionRule as DeductionRule; + $configBase = ['section' => 'supplier-payables', 'page' => 'manufacturer-bookkeeping', 'bk_view' => 'config']; + $actionLabels = ['create' => '新增', 'update' => '编辑', 'delete' => '删除', 'restore' => '恢复', 'import' => '导入']; +@endphp +
+
+

按供应商名称(与聚工单商品明细中的供应商完全一致)配置厂家记账方式,记账明细的“厂家扣款判定”按此实时计算。

+
+ @if (session('bookkeeping_config_message'))
{{ session('bookkeeping_config_message') }}
@endif + @if ($errors->any())@endif +
+ @foreach (DeductionRule::MODES as $mode => $label) +
{{ $label }}{{ number_format($configCounts[$mode] ?? 0) }}
+ @endforeach +
配置供应商合计{{ number_format(array_sum($configCounts)) }}
+
+
+ + + + + + 重置 + +
+ + + 导出配置 + 正常记账:厂家记账全部扣款;仅代发仓发错货记账:只扣代发仓“仓库发错货”;不记账:均不扣。生效期外按“待配置”处理。 +
+
+
@forelse(array_filter(explode("\n", (string) $row['attachment'])) as $i => $url)附件{{ $i + 1 }} @empty — @endforelse{{ $row['status'] }}{{ $row['deduction_status'] }}{{ ($row[$key] ?? '') !== '' ? $row[$key] : '—' }}
+ @forelse ($configRows as $config) + + + + + + + + + + + @empty + + @endforelse +
供应商记账方式生效期原始说明创建人更新时间删除状态操作
{{ $config->supplier_name }}{{ DeductionRule::MODES[$config->mode] ?? $config->mode }}{{ $config->effective_from || $config->effective_to ? ($config->effective_from ?: '不限') . ' 至 ' . ($config->effective_to ?: '不限') : '长期' }}{{ $config->source_text !== '' ? $config->source_text : '—' }}{{ $config->creator }}{{ $config->updated_at }}{{ $config->deleted_at ? '已删除' : '正常' }} + @if (! $config->deleted_at) + +
@csrf @method('DELETE')
+ @else +
@csrf
+ @endif +
暂无符合条件的厂家记账配置。
+ +
+

操作日志

+
+ @forelse ($configEvents as $event) + + @empty + + @endforelse +
操作时间操作用户操作类型执行结果描述
{{ $event->created_at }}{{ $event->actor }}{{ $actionLabels[$event->action] ?? $event->action }}{{ $event->description }}
暂无操作记录。
+ + +
+ @csrf +

新增厂家记账配置

+ + +
+ + +
+ +

生效日期留空表示长期有效;工单发起日期不在生效期内时按“待配置”处理。

+
+
+
+ +
@csrf +

导入厂家记账配置

+

支持财务原表(列:供应商、厂家是否记账,如“代发仓发货错需记账,其他原因均不记账”“不记账(2025.3月-2027.12.30)”)或本页导出的配置表。

+ +

新供应商新增;已有供应商记账方式或生效期不同时按文件更新(已删除的一并恢复);相同则跳过。任一行无法识别时整批不导入。

+
+
+
+
+ diff --git a/routes/admin.php b/routes/admin.php index 2c852f32..aaa2284c 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -475,6 +475,19 @@ Route::get('workspace/supplier-payables/manufacturer-bookkeeping/export', [\App\ ->middleware('business.page:supplier-payables,manufacturer-bookkeeping') ->name('business.supplier-bookkeeping.export'); +Route::prefix('workspace/supplier-payables/manufacturer-bookkeeping/configs') + ->name('business.supplier-bookkeeping-configs.') + ->middleware('business.page:supplier-payables,manufacturer-bookkeeping') + ->group(function (): void { + $controller = \App\Http\Controllers\Business\SupplierBookkeepingConfigs::class; + Route::get('export', [$controller, 'export'])->name('export'); + Route::post('import', [$controller, 'import'])->name('import'); + Route::post('/', [$controller, 'store'])->name('store'); + Route::patch('{configId}', [$controller, 'update'])->whereNumber('configId')->name('update'); + Route::delete('{configId}', [$controller, 'destroy'])->whereNumber('configId')->name('destroy'); + Route::post('{configId}/restore', [$controller, 'restore'])->whereNumber('configId')->name('restore'); + }); + Route::get('workspace/courier-payables/shipping-data/export', 'Business\Pages@exportShippingData') ->middleware('business.page:courier-payables,shipping-data') ->name('business.courier-payables.shipping-data.export'); diff --git a/tests/Feature/Business/SupplierBookkeepingConfigTest.php b/tests/Feature/Business/SupplierBookkeepingConfigTest.php new file mode 100644 index 00000000..801e8d10 --- /dev/null +++ b/tests/Feature/Business/SupplierBookkeepingConfigTest.php @@ -0,0 +1,120 @@ +user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + $repository = \Mockery::mock(SupplierBookkeepingWorkOrderRepository::class); + $repository->shouldReceive('syncStatus')->andReturn(['code' => 'ready', 'last_succeeded_at' => null]); + $repository->shouldReceive('rows')->andReturn([]); + $this->app->instance(SupplierBookkeepingWorkOrderRepository::class, $repository); + } + + private function url(string $name, array $params = []): string + { + return route($name, ['company_id' => $this->company->id] + $params); + } + + private function configPage(array $query = []): string + { + return route('business.pages.show', ['company_id' => $this->company->id, 'section' => 'supplier-payables', 'page' => 'manufacturer-bookkeeping', 'bk_view' => 'config'] + $query); + } + + private function xlsx(array $rows): UploadedFile + { + $book = new Spreadsheet(); + $book->getActiveSheet()->fromArray($rows); + $path = tempnam(sys_get_temp_dir(), 'sbc') . '.xlsx'; + (new Xlsx($book))->save($path); + + return new UploadedFile($path, '厂家记账配置.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', null, true); + } + + public function test_migration_imports_existing_finance_configs(): void + { + $this->assertSame(209, DB::table(Store::TABLE)->count()); + $this->assertSame(4, DB::table(Store::TABLE)->where('mode', 'normal')->count()); + $this->assertSame(48, DB::table(Store::TABLE)->where('mode', 'dropship_wrong')->count()); + $row = DB::table(Store::TABLE)->where('supplier_name', '景瓴十方')->first(); + $this->assertSame(['none', '2025-03-01', '2026-02-28'], [$row->mode, $row->effective_from, $row->effective_to]); + + $this->loginAs()->get($this->configPage(['supplier_name' => '曹县洪耀'])) + ->assertOk() + ->assertSee('厂家记账配置') + ->assertSee('曹县洪耀') + ->assertSee('代发仓发货错需记账,其他原因均不记账') + ->assertSee('导入存量厂家记账配置:新增209条'); + } + + public function test_create_update_delete_restore_with_audit_log(): void + { + $this->loginAs()->post($this->url('business.supplier-bookkeeping-configs.store'), ['supplier_name' => ' 新厂家 ', 'mode' => 'normal']) + ->assertRedirect(); + $id = DB::table(Store::TABLE)->where('supplier_name', '新厂家')->value('id'); + $this->assertNotNull($id); + + $this->withExceptionHandling()->loginAs()->post($this->url('business.supplier-bookkeeping-configs.store'), ['supplier_name' => '新厂家', 'mode' => 'none']) + ->assertSessionHasErrors('supplier_name'); + + $this->loginAs()->patch($this->url('business.supplier-bookkeeping-configs.update', ['configId' => $id]), [ + 'supplier_name' => '新厂家', 'mode' => 'none', 'effective_from' => '2026-01-01', 'effective_to' => '2026-12-31', + ])->assertRedirect(); + $this->assertSame('none', DB::table(Store::TABLE)->where('id', $id)->value('mode')); + + $this->withExceptionHandling()->loginAs()->patch($this->url('business.supplier-bookkeeping-configs.update', ['configId' => $id]), [ + 'supplier_name' => '新厂家', 'mode' => 'none', 'effective_from' => '2026-12-31', 'effective_to' => '2026-01-01', + ])->assertSessionHasErrors('effective_to'); + + $this->loginAs()->delete($this->url('business.supplier-bookkeeping-configs.destroy', ['configId' => $id]))->assertRedirect(); + $this->assertNotNull(DB::table(Store::TABLE)->where('id', $id)->value('deleted_at')); + $this->loginAs()->post($this->url('business.supplier-bookkeeping-configs.restore', ['configId' => $id]))->assertRedirect(); + $this->assertNull(DB::table(Store::TABLE)->where('id', $id)->value('deleted_at')); + + $this->assertSame(['import', 'create', 'update', 'delete', 'restore'], DB::table(Store::EVENTS)->orderBy('id')->pluck('action')->all()); + } + + public function test_import_finance_sheet_adds_updates_and_skips(): void + { + $file = $this->xlsx([ + ['供应商', '厂家是否记账'], + ['鑫呈', '正常记账'], + ['尊享', '代发仓发货错需记账,其他原因均不记账'], + ['供应商', '厂家是否记账'], + ['全新厂', '不记账(2025.3月-2027.12.30)'], + ]); + $this->loginAs()->post($this->url('business.supplier-bookkeeping-configs.import'), ['file' => $file]) + ->assertRedirect() + ->assertSessionHas('bookkeeping_config_message', '导入完成:新增1条,更新1条,未变化1条。'); + $this->assertSame('dropship_wrong', DB::table(Store::TABLE)->where('supplier_name', '尊享')->value('mode')); + $this->assertSame('2027-12-30', DB::table(Store::TABLE)->where('supplier_name', '全新厂')->value('effective_to')); + + $before = DB::table(Store::TABLE)->count(); + $bad = $this->xlsx([['供应商', '厂家是否记账'], ['再一个厂', '正常记账'], ['坏行厂', '看情况']]); + $this->withExceptionHandling()->loginAs()->post($this->url('business.supplier-bookkeeping-configs.import'), ['file' => $bad]) + ->assertSessionHasErrors('file'); + $this->assertSame($before, DB::table(Store::TABLE)->count()); + } + + public function test_export_round_trips_through_import(): void + { + $response = $this->loginAs()->get($this->url('business.supplier-bookkeeping-configs.export', ['supplier_name' => '景瓴十方'])); + $response->assertOk(); + $path = tempnam(sys_get_temp_dir(), 'sbe') . '.xlsx'; + file_put_contents($path, $response->streamedContent()); + $rows = app(\Modules\EcommerceFinance\Services\SupplierBookkeepingConfigImporter::class)->read($path); + $this->assertSame([['supplier_name' => '景瓴十方', 'mode' => 'none', 'effective_from' => '2025-03-01', 'effective_to' => '2026-02-28', 'source_text' => '不记账(2025.3月-2026.2月)']], $rows); + } +} diff --git a/tests/Feature/Business/SupplierBookkeepingPageTest.php b/tests/Feature/Business/SupplierBookkeepingPageTest.php index 96b8760a..d7377a7a 100644 --- a/tests/Feature/Business/SupplierBookkeepingPageTest.php +++ b/tests/Feature/Business/SupplierBookkeepingPageTest.php @@ -87,4 +87,44 @@ class SupplierBookkeepingPageTest extends FeatureTestCase $this->assertStringContainsString("GD614939067,'5127787476014026744,382.15", $csv); $this->assertStringNotContainsString('GD-2', $csv); } + + public function test_seeded_configs_drive_deduction_judgement_totals_and_filter(): void + { + $this->mockRepository([ + $this->row(), + $this->row(['work_order_no' => 'GD-NORMAL', 'supplier' => '鑫呈', 'amount' => '100.00']), + $this->row(['work_order_no' => 'GD-NONE', 'supplier' => '尊享', 'amount' => '50.00']), + $this->row(['work_order_no' => 'GD-DROP-OTHER', 'supplier' => '曹县洪耀', 'amount' => '30.00']), + $this->row(['work_order_no' => 'GD-DROP-WRONG', 'supplier' => '曹县洪耀', 'amount' => '20.50', 'reason_level_1' => '仓库记账', + 'reason_level_2' => '仓库发错货(面单照片,实物照片,条形码照片)', 'bookkeeping_reason' => '仓库记账/仓库发错货(面单照片,实物照片,条形码照片)', 'shipping_warehouse' => '曹县洪耀代发仓']), + $this->row(['work_order_no' => 'GD-MAIN-WRONG', 'supplier' => '曹县洪耀', 'amount' => '8.00', 'reason_level_1' => '仓库记账', + 'reason_level_2' => '仓库发错货(面单照片,实物照片,条形码照片)', 'bookkeeping_reason' => '仓库记账/仓库发错货(面单照片,实物照片,条形码照片)', 'shipping_warehouse' => '常州主仓']), + ]); + + $this->loginAs()->get($this->url()) + ->assertOk() + ->assertSee('厂家扣款判定') + ->assertSee('仅代发仓发错货记账') + ->assertSee('120.50') + ->assertSee('1 / 0'); + + $this->loginAs()->get($this->url(['deduction' => '扣款'])) + ->assertOk() + ->assertSee('GD-NORMAL') + ->assertSee('GD-DROP-WRONG') + ->assertDontSee('GD-NONE') + ->assertDontSee('GD-DROP-OTHER') + ->assertDontSee('GD-MAIN-WRONG'); + + $this->loginAs()->get($this->url(['deduction' => '待配置']))->assertOk()->assertSee('GD614939067')->assertDontSee('GD-NORMAL'); + + $csv = $this->loginAs()->get(route('business.supplier-bookkeeping.export', [ + 'company_id' => $this->company->id, 'deduction' => '扣款', 'columns' => 'work_order_no,deduction_status,deduction_basis', + ]))->assertOk()->streamedContent(); + $this->assertStringContainsString('工单编号,厂家扣款判定,判定说明', $csv); + $this->assertStringContainsString('GD-DROP-WRONG,扣款,代发仓发错货,记厂家扣款', $csv); + $this->assertStringNotContainsString('GD-NONE', $csv); + + $this->withExceptionHandling()->loginAs()->get($this->url(['deduction' => 'x']))->assertStatus(422); + } } diff --git a/tests/Unit/EcommerceFinance/SupplierBookkeepingDeductionRuleTest.php b/tests/Unit/EcommerceFinance/SupplierBookkeepingDeductionRuleTest.php new file mode 100644 index 00000000..24413403 --- /dev/null +++ b/tests/Unit/EcommerceFinance/SupplierBookkeepingDeductionRuleTest.php @@ -0,0 +1,86 @@ +assertSame(['mode' => Rule::MODE_NONE, 'effective_from' => null, 'effective_to' => null], Rule::parseRuleText('不记账')); + $this->assertSame(Rule::MODE_NORMAL, Rule::parseRuleText('正常记账')['mode']); + $this->assertSame(Rule::MODE_DROPSHIP_WRONG, Rule::parseRuleText('代发仓发货错需记账,其他原因均不记账')['mode']); + $this->assertSame(Rule::MODE_DROPSHIP_WRONG, Rule::parseRuleText('厂家暂未出货,后期若建立代发仓,发货错需记账,其他原因均不记账')['mode']); + $this->assertSame(['mode' => Rule::MODE_NONE, 'effective_from' => '2025-03-01', 'effective_to' => '2026-02-28'], Rule::parseRuleText('不记账(2025.3月-2026.2月)')); + $this->assertSame(['mode' => Rule::MODE_NONE, 'effective_from' => '2025-03-01', 'effective_to' => '2027-12-30'], Rule::parseRuleText('不记账(2025.3月-2027.12.30)')); + $this->assertSame(Rule::MODE_DROPSHIP_WRONG, Rule::parseRuleText('仅代发仓发错货记账')['mode']); + } + + public function test_rejects_unknown_rule_text(): void + { + $this->expectException(InvalidArgumentException::class); + Rule::parseRuleText('看情况'); + } + + private function row(array $patch = []): array + { + return array_replace([ + 'started_at' => '2026-09-05 10:00:00', 'reason_level_1' => '厂家记账', 'reason_level_2' => '孔位错误', + 'shipping_warehouse' => '常州主仓', 'supplier' => '甲厂', + ], $patch); + } + + private function configs(): array + { + return [ + '甲厂' => ['mode' => Rule::MODE_NORMAL, 'effective_from' => null, 'effective_to' => null], + '乙厂' => ['mode' => Rule::MODE_DROPSHIP_WRONG, 'effective_from' => null, 'effective_to' => null], + '丙厂' => ['mode' => Rule::MODE_NONE, 'effective_from' => null, 'effective_to' => null], + '丁厂' => ['mode' => Rule::MODE_NONE, 'effective_from' => '2025-03-01', 'effective_to' => '2026-02-28'], + ]; + } + + private function judge(array $patch): string + { + return Rule::decide($this->row($patch), $this->configs())['deduction_status']; + } + + public function test_supplier_reasons_follow_supplier_config(): void + { + $this->assertSame(Rule::STATUS_DEDUCT, $this->judge([])); + $this->assertSame(Rule::STATUS_SKIP, $this->judge(['supplier' => '乙厂'])); + $this->assertSame(Rule::STATUS_SKIP, $this->judge(['supplier' => '丙厂'])); + $this->assertSame(Rule::STATUS_UNCONFIGURED, $this->judge(['supplier' => '未知厂'])); + $this->assertSame(Rule::STATUS_UNCONFIGURED, $this->judge(['supplier' => ''])); + // 限期不记账:期外不自动推断,提示补配置。 + $this->assertSame(Rule::STATUS_UNCONFIGURED, $this->judge(['supplier' => '丁厂'])); + $this->assertSame(Rule::STATUS_SKIP, $this->judge(['supplier' => '丁厂', 'started_at' => '2026-02-28 23:00:00'])); + } + + public function test_warehouse_reasons_only_deduct_dropship_wrong_shipment(): void + { + $wrong = ['reason_level_1' => '仓库记账', 'reason_level_2' => '仓库发错货(面单照片,实物照片,条形码照片)', 'shipping_warehouse' => '曹县洪耀代发仓']; + $this->assertSame(Rule::STATUS_DEDUCT, $this->judge($wrong + ['supplier' => '乙厂'])); + $this->assertSame(Rule::STATUS_DEDUCT, $this->judge($wrong)); + $this->assertSame(Rule::STATUS_SKIP, $this->judge($wrong + ['supplier' => '丙厂'])); + $this->assertSame(Rule::STATUS_UNCONFIGURED, $this->judge($wrong + ['supplier' => '未知厂'])); + // 非代发仓、或代发仓的其他仓库记账原因:一律不作为厂家扣款项,无需配置。 + $this->assertSame(Rule::STATUS_SKIP, $this->judge(['shipping_warehouse' => '常州主仓'] + $wrong + ['supplier' => '乙厂'])); + $this->assertSame(Rule::STATUS_SKIP, $this->judge(['reason_level_2' => '仓库双面单'] + $wrong + ['supplier' => '乙厂'])); + $this->assertSame(Rule::STATUS_SKIP, $this->judge(['reason_level_2' => '退货运费'] + $wrong + ['supplier' => '未知厂'])); + } + + public function test_multiple_suppliers_need_review_when_outcomes_differ(): void + { + $this->assertSame(Rule::STATUS_DEDUCT, $this->judge(['supplier' => "甲厂\n甲厂"])); + $result = Rule::decide($this->row(['supplier' => "甲厂\n丙厂"]), $this->configs()); + $this->assertSame(Rule::STATUS_REVIEW, $result['deduction_status']); + $this->assertStringContainsString('甲厂:扣款', $result['deduction_basis']); + $this->assertSame("正常记账\n不记账", $result['supplier_config']); + } +}