diff --git a/.serena/cache/php/document_symbols_cache_v23-06-25.pkl b/.serena/cache/php/document_symbols_cache_v23-06-25.pkl index 420c1a2..888989e 100644 Binary files a/.serena/cache/php/document_symbols_cache_v23-06-25.pkl and b/.serena/cache/php/document_symbols_cache_v23-06-25.pkl differ diff --git a/.serena/project.yml b/.serena/project.yml index 68515c6..d55bff4 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -1,17 +1,10 @@ -# language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby) -# * For C, use cpp -# * For JavaScript, use typescript -# Special requirements: -# * csharp: Requires the presence of a .sln file in the project folder. -language: php -# whether to use the project's gitignore file to ignore files -# Added on 2025-04-07 +# whether to use project's .gitignore files to ignore files ignore_all_files_in_gitignore: true -# list of additional paths to ignore -# same syntax as gitignore, so you can use * and ** -# Was previously called `ignored_dirs`, please update your config if you are using that. -# Added (renamed) on 2025-04-07 + +# list of additional paths to ignore in this project. +# Same syntax as gitignore, so you can use * and **. +# Note: global ignored_paths from serena_config.yml are also applied additively. ignored_paths: [] # whether the project is in read-only mode @@ -19,50 +12,143 @@ ignored_paths: [] # Added on 2025-04-18 read_only: false - -# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details. +# list of tool names to exclude. +# This extends the existing exclusions (e.g. from the global configuration) +# # Below is the complete list of tools for convenience. # To make sure you have the latest list of tools, and to view their descriptions, # execute `uv run scripts/print_tool_overview.py`. # -# * `activate_project`: Activates a project by name. +# * `activate_project`: Activates a project based on the project name or path. # * `check_onboarding_performed`: Checks whether project onboarding was already performed. # * `create_text_file`: Creates/overwrites a file in the project directory. -# * `delete_lines`: Deletes a range of lines within a file. -# * `delete_memory`: Deletes a memory from Serena's project-specific memory store. +# * `delete_memory`: Delete a memory file. Should only happen if a user asks for it explicitly, +# for example by saying that the information retrieved from a memory file is no longer correct +# or no longer relevant for the project. +# * `edit_memory`: Replaces content matching a regular expression in a memory. # * `execute_shell_command`: Executes a shell command. -# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. -# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). -# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). +# * `find_file`: Finds files in the given relative paths +# * `find_referencing_symbols`: Finds symbols that reference the given symbol using the language server backend +# * `find_symbol`: Performs a global (or local) search using the language server backend. # * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. # * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. -# * `initial_instructions`: Gets the initial instructions for the current project. -# Should only be used in settings where the system prompt cannot be set, -# e.g. in clients you have no control over, like Claude Desktop. +# * `initial_instructions`: Provides instructions Serena usage (i.e. the 'Serena Instructions Manual') +# for clients that do not read the initial instructions when the MCP server is connected. # * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. -# * `insert_at_line`: Inserts content at a given line in a file. # * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. # * `list_dir`: Lists files and directories in the given directory (optionally with recursion). -# * `list_memories`: Lists memories in Serena's project-specific memory store. +# * `list_memories`: List available memories. Any memory can be read using the `read_memory` tool. # * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). -# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). # * `read_file`: Reads a file within the project directory. -# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. -# * `remove_project`: Removes a project from the Serena configuration. -# * `replace_lines`: Replaces a range of lines within a file with new content. -# * `replace_symbol_body`: Replaces the full definition of a symbol. -# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. +# * `read_memory`: Read the content of a memory file. This tool should only be used if the information +# is relevant to the current task. You can infer whether the information +# is relevant from the memory file name. +# You should not read the same memory file multiple times in the same conversation. +# * `rename_memory`: Renames or moves a memory. Moving between project and global scope is supported +# (e.g., renaming "global/foo" to "bar" moves it from global to project scope). +# * `rename_symbol`: Renames a symbol throughout the codebase using language server refactoring capabilities. +# For JB, we use a separate tool. +# * `replace_content`: Replaces content in a file (optionally using regular expressions). +# * `replace_symbol_body`: Replaces the full definition of a symbol using the language server backend. +# * `safe_delete_symbol`: # * `search_for_pattern`: Performs a search for a pattern in the project. -# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. -# * `switch_modes`: Activates modes by providing a list of their names -# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. -# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. -# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. -# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. +# * `write_memory`: Write some information (utf-8-encoded) about this project that can be useful for future tasks to a memory in md format. +# The memory name should be meaningful. excluded_tools: [] # initial prompt for the project. It will always be given to the LLM upon activating the project # (contrary to the memories, which are loaded on demand). initial_prompt: "" - +# the name by which the project can be referenced within Serena project_name: "toolbox" + +# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default). +# This extends the existing inclusions (e.g. from the global configuration). +included_optional_tools: [] + +# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. +# This cannot be combined with non-empty excluded_tools or included_optional_tools. +fixed_tools: [] + +# list of mode names to that are always to be included in the set of active modes +# The full set of modes to be activated is base_modes + default_modes. +# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this setting overrides the global configuration. +# Set this to [] to disable base modes for this project. +# Set this to a list of mode names to always include the respective modes for this project. +base_modes: + +# list of mode names that are to be activated by default. +# The full set of modes to be activated is base_modes + default_modes. +# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this overrides the setting from the global configuration (serena_config.yml). +# This setting can, in turn, be overridden by CLI parameters (--mode). +default_modes: + +# time budget (seconds) per tool call for the retrieval of additional symbol information +# such as docstrings or parameter information. +# This overrides the corresponding setting in the global configuration; see the documentation there. +# If null or missing, use the setting from the global configuration. +symbol_info_budget: + +# The language backend to use for this project. +# If not set, the global setting from serena_config.yml is used. +# Valid values: LSP, JetBrains +# Note: the backend is fixed at startup. If a project with a different backend +# is activated post-init, an error will be returned. +language_backend: + +# line ending convention to use when writing source files. +# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default) +# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings. +line_ending: + +# list of regex patterns which, when matched, mark a memory entry as read‑only. +# Extends the list from the global configuration, merging the two lists. +read_only_memory_patterns: [] + +# list of regex patterns for memories to completely ignore. +# Matching memories will not appear in list_memories or activate_project output +# and cannot be accessed via read_memory or write_memory. +# To access ignored memory files, use the read_file tool on the raw file path. +# Extends the list from the global configuration, merging the two lists. +# Example: ["_archive/.*", "_episodes/.*"] +ignored_memory_patterns: [] + +# advanced configuration option allowing to configure language server-specific options. +# Maps the language key to the options. +# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available. +# No documentation on options means no options are available. +ls_specific_settings: {} + +# the encoding used by text files in the project +# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings +encoding: utf-8 + + +# list of languages for which language servers are started; choose from: +# al bash clojure cpp csharp +# csharp_omnisharp dart elixir elm erlang +# fortran fsharp go groovy haskell +# haxe java julia kotlin lua +# markdown +# matlab nix pascal perl php +# php_phpactor powershell python python_jedi r +# rego ruby ruby_solargraph rust scala +# swift terraform toml typescript typescript_vts +# vue yaml zig +# (This list may be outdated. For the current list, see values of Language enum here: +# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py +# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) +# Note: +# - For C, use cpp +# - For JavaScript, use typescript +# - For Free Pascal/Lazarus, use pascal +# Special requirements: +# Some languages require additional setup/installations. +# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers +# When using multiple languages, the first language server that supports a given file will be used for that file. +# The first language is the default language and the respective language server will be used as a fallback. +# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. +languages: +- php diff --git a/app/Http/Controllers/JiraController.php b/app/Http/Controllers/JiraController.php index ffac2af..7b2f923 100644 --- a/app/Http/Controllers/JiraController.php +++ b/app/Http/Controllers/JiraController.php @@ -4,11 +4,13 @@ namespace App\Http\Controllers; use App\Services\JiraService; use Carbon\Carbon; -use Illuminate\Http\Request; use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; class JiraController extends Controller { + private const WEEKLY_REPORT_PERIODS = ['this_week', 'last_week']; + private JiraService $jiraService; public function __construct(JiraService $jiraService) @@ -16,37 +18,44 @@ class JiraController extends Controller $this->jiraService = $jiraService; } - - /** - * 生成上周周报 + * 生成周报 */ public function generateWeeklyReport(Request $request): JsonResponse { try { $username = $request->input('username') ?: config('jira.default_user'); + $period = $request->input('period', 'this_week'); - if (!$username) { + if (! $username) { return response()->json([ 'success' => false, - 'message' => '请提供用户名' + 'message' => '请提供用户名', ], 400); } - $report = $this->jiraService->generateWeeklyReport($username); + if (! in_array($period, self::WEEKLY_REPORT_PERIODS, true)) { + return response()->json([ + 'success' => false, + 'message' => '无效的周报周期', + ], 400); + } + + $report = $this->jiraService->generateWeeklyReport($username, $period); return response()->json([ 'success' => true, 'data' => [ 'report' => $report, 'username' => $username, - 'generated_at' => Carbon::now()->format('Y-m-d H:i:s') - ] + 'period' => $period, + 'generated_at' => Carbon::now()->format('Y-m-d H:i:s'), + ], ]); } catch (\Exception $e) { return response()->json([ 'success' => false, - 'message' => '生成周报失败: ' . $e->getMessage() + 'message' => '生成周报失败: '.$e->getMessage(), ], 500); } } @@ -77,14 +86,14 @@ class JiraController extends Controller 'total_records' => $workLogs->count(), 'date_range' => [ 'start' => $startDate->format('Y-m-d'), - 'end' => $endDate->format('Y-m-d') - ] - ] + 'end' => $endDate->format('Y-m-d'), + ], + ], ]); } catch (\Exception $e) { return response()->json([ 'success' => false, - 'message' => '获取工时记录失败: ' . $e->getMessage() + 'message' => '获取工时记录失败: '.$e->getMessage(), ], 500); } } @@ -98,8 +107,8 @@ class JiraController extends Controller 'success' => true, 'data' => [ 'default_user' => config('jira.default_user', ''), - 'host' => config('jira.host', '') - ] + 'host' => config('jira.host', ''), + ], ]); } @@ -110,26 +119,33 @@ class JiraController extends Controller { try { $username = $request->input('username') ?: config('jira.default_user'); + $period = $request->input('period', 'this_week'); - if (!$username) { + if (! $username) { return response()->json([ 'success' => false, - 'message' => '请提供用户名' + 'message' => '请提供用户名', ], 400); } - $report = $this->jiraService->generateWeeklyReport($username); - $filename = sprintf('weekly_report_%s_%s.md', $username, Carbon::now()->subWeek()->format('Y-m-d')); + if (! in_array($period, self::WEEKLY_REPORT_PERIODS, true)) { + return response()->json([ + 'success' => false, + 'message' => '无效的周报周期', + ], 400); + } + + $report = $this->jiraService->generateWeeklyReport($username, $period); + $filename = sprintf('weekly_report_%s_%s_%s.md', $username, $period, Carbon::now()->format('Y-m-d')); return response($report) ->header('Content-Type', 'text/markdown') - ->header('Content-Disposition', 'attachment; filename="' . $filename . '"'); + ->header('Content-Disposition', 'attachment; filename="'.$filename.'"'); } catch (\Exception $e) { return response()->json([ 'success' => false, - 'message' => '下载周报失败: ' . $e->getMessage() + 'message' => '下载周报失败: '.$e->getMessage(), ], 500); } } - } diff --git a/app/Http/Controllers/SqlGeneratorController.php b/app/Http/Controllers/SqlGeneratorController.php index d0b55de..fe16182 100644 --- a/app/Http/Controllers/SqlGeneratorController.php +++ b/app/Http/Controllers/SqlGeneratorController.php @@ -25,7 +25,7 @@ class SqlGeneratorController extends Controller if (empty($caseCodes)) { return response()->json([ 'success' => false, - 'message' => '请提供有效的 case_id 列表' + 'message' => '请提供有效的 case_id 列表', ], 400); } @@ -59,8 +59,212 @@ class SqlGeneratorController extends Controller } catch (\Exception $e) { return response()->json([ 'success' => false, - 'message' => '查询 case_extras 失败: ' . $e->getMessage(), + 'message' => '查询 case_extras 失败: '.$e->getMessage(), ], 500); } } + + /** + * 查询 CRM 加工单关联地址国家,用于区分 PP-CN / PP-US。 + */ + public function checkProductionCountries(Request $request): JsonResponse + { + try { + $request->validate([ + 'production_codes' => 'required|array|min:1', + 'production_codes.*' => 'required|string|max:255', + ]); + + $productionCodes = array_values(array_unique(array_filter(array_map('trim', $request->input('production_codes'))))); + + if (empty($productionCodes)) { + return response()->json([ + 'success' => false, + 'message' => '请提供有效的加工单列表', + ], 400); + } + + $productionCountries = $this->getProductionCountries($productionCodes); + + return response()->json([ + 'success' => true, + 'data' => [ + 'production_countries' => $productionCountries, + ], + ]); + } catch (ValidationException $e) { + return response()->json([ + 'success' => false, + 'message' => '请求参数验证失败', + 'errors' => $e->errors(), + ], 422); + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'message' => '查询 CRM 加工单国家失败: '.$e->getMessage(), + ], 500); + } + } + + private function getProductionCountries(array $productionCodes): array + { + $productionCountries = []; + + foreach (array_chunk($productionCodes, 1000) as $chunk) { + $productions = DB::connection('crmslave') + ->table('ea_production as ep') + ->join('ea_production_cstm as epc', 'ep.id', '=', 'epc.id_c') + ->where('ep.deleted', 0) + ->whereIn('ep.name', $chunk) + ->select([ + 'ep.name as production_code', + 'epc.ea_case_id_c', + 'epc.ea_businessorder_id_c', + 'epc.ea_salesorder_id_c', + ]) + ->get(); + + $caseIds = $productions->pluck('ea_case_id_c')->filter()->unique()->values()->all(); + $businessOrderIds = $productions->pluck('ea_businessorder_id_c')->filter()->unique()->values()->all(); + $salesOrderIds = $productions->pluck('ea_salesorder_id_c')->filter()->unique()->values()->all(); + + $caseCountries = $this->getCountriesByCaseIds($caseIds); + $businessOrderCountries = $this->getCountriesByBusinessOrderIds($businessOrderIds); + $salesOrderCountries = $this->getCountriesBySalesOrderIds($salesOrderIds); + + foreach ($productions as $production) { + $countries = array_merge( + $caseCountries[(string) $production->ea_case_id_c] ?? [], + $businessOrderCountries[(string) $production->ea_businessorder_id_c] ?? [], + $salesOrderCountries[(string) $production->ea_salesorder_id_c] ?? [] + ); + + $productionCountries[(string) $production->production_code] = array_values(array_unique(array_filter($countries))); + } + } + + return $productionCountries; + } + + private function getCountriesByCaseIds(array $caseIds): array + { + if (empty($caseIds)) { + return []; + } + + $results = DB::connection('crmslave') + ->table('ea_case as ec') + ->join('accounts_ea_case_1_c as aec1c', function ($join) { + $join->on('aec1c.accounts_ea_case_1ea_case_idb', '=', 'ec.id') + ->where('aec1c.deleted', '=', 0); + }) + ->join('accounts as a', 'a.id', '=', 'aec1c.accounts_ea_case_1accounts_ida') + ->join('accounts_cstm as ac', 'ac.id_c', '=', 'a.id') + ->where('ec.deleted', 0) + ->where('a.deleted', 0) + ->whereIn('ec.id', $caseIds) + ->whereNotNull('ac.country_c') + ->select([ + 'ec.id as entity_id', + 'ac.country_c as country', + 'ac.province_c as province', + ]) + ->get(); + + return $this->groupCountriesByEntityId($results); + } + + private function getCountriesByBusinessOrderIds(array $businessOrderIds): array + { + if (empty($businessOrderIds)) { + return []; + } + + $results = DB::connection('crmslave') + ->table('ea_businessorder as eb') + ->join('accounts_ea_businessorder_1_c as aeb1c', function ($join) { + $join->on('aeb1c.accounts_ea_businessorder_1ea_businessorder_idb', '=', 'eb.id') + ->where('aeb1c.deleted', '=', 0); + }) + ->join('accounts as a', 'a.id', '=', 'aeb1c.accounts_ea_businessorder_1accounts_ida') + ->join('accounts_cstm as ac', 'ac.id_c', '=', 'a.id') + ->where('eb.deleted', 0) + ->where('a.deleted', 0) + ->whereIn('eb.id', $businessOrderIds) + ->whereNotNull('ac.country_c') + ->select([ + 'eb.id as entity_id', + 'ac.country_c as country', + 'ac.province_c as province', + ]) + ->get(); + + return $this->groupCountriesByEntityId($results); + } + + private function getCountriesBySalesOrderIds(array $salesOrderIds): array + { + if (empty($salesOrderIds)) { + return []; + } + + $results = DB::connection('crmslave') + ->table('ea_salesorder as es') + ->join('accounts_ea_salesorder_1_c as aes1c', function ($join) { + $join->on('aes1c.accounts_ea_salesorder_1ea_salesorder_idb', '=', 'es.id') + ->where('aes1c.deleted', '=', 0); + }) + ->join('accounts as a_base', 'a_base.id', '=', 'aes1c.accounts_ea_salesorder_1accounts_ida') + ->join('accounts_cstm as ac', 'ac.id_c', '=', 'aes1c.accounts_ea_salesorder_1accounts_ida') + ->where('es.deleted', 0) + ->where('a_base.deleted', 0) + ->whereIn('es.id', $salesOrderIds) + ->whereNotNull('ac.country_c') + ->select([ + 'es.id as entity_id', + 'ac.country_c as country', + 'ac.province_c as province', + ]) + ->get(); + + return $this->groupCountriesByEntityId($results); + } + + private function groupCountriesByEntityId($results): array + { + $countriesByEntityId = []; + + foreach ($results as $result) { + $countryCode = $this->getCountryCode($result->country, $result->province); + if (! $countryCode) { + continue; + } + + $entityId = (string) $result->entity_id; + $countriesByEntityId[$entityId] ??= []; + $countriesByEntityId[$entityId][] = $countryCode; + } + + return array_map(fn ($countries) => array_values(array_unique($countries)), $countriesByEntityId); + } + + private function getCountryCode(?string $country, ?string $province): ?string + { + if (! $country) { + return null; + } + + if (in_array($country, ['1', '156'], true) && ! in_array((string) $province, ['710000', '810000', '820000'], true)) { + return 'CN'; + } + + return [ + '840' => 'US', + 'US' => 'US', + '316' => 'GU', + 'GU' => 'GU', + '630' => 'PR', + 'PR' => 'PR', + ][strtoupper($country)] ?? strtoupper($country); + } } diff --git a/app/Services/JiraService.php b/app/Services/JiraService.php index 7c635ff..17c9ff7 100644 --- a/app/Services/JiraService.php +++ b/app/Services/JiraService.php @@ -2,17 +2,19 @@ namespace App\Services; +use Carbon\Carbon; +use Illuminate\Support\Collection; use JiraRestApi\Configuration\ArrayConfiguration; use JiraRestApi\Issue\IssueService; use JiraRestApi\JiraException; use JiraRestApi\Project\ProjectService; -use Carbon\Carbon; -use Illuminate\Support\Collection; class JiraService { private IssueService $issueService; + private ProjectService $projectService; + private array $config; public function __construct() @@ -39,7 +41,6 @@ class JiraService $this->projectService = new ProjectService($clientConfig); } - /** * 按项目组织任务数据 */ @@ -51,7 +52,7 @@ class JiraService $projectKey = $issue->fields->project->key; $isSubtask = $issue->fields->issuetype->subtask ?? false; - if (!$organized->has($projectKey)) { + if (! $organized->has($projectKey)) { $organized->put($projectKey, [ 'name' => $issue->fields->project->name, 'tasks' => collect(), @@ -81,7 +82,7 @@ class JiraService 'summary', 'status', 'project', - 'issuetype' + 'issuetype', ]); } catch (JiraException) { return null; @@ -93,14 +94,14 @@ class JiraService $tasks->put($issue->key, [ 'key' => $issue->key, 'summary' => $issue->fields->summary, - 'url' => $this->config['host'] . '/browse/' . $issue->key, + 'url' => $this->config['host'].'/browse/'.$issue->key, 'subtasks' => collect(), ]); } private function addSubtask(Collection $tasks, string $parentKey, $issue): void { - if (!$tasks->has($parentKey)) { + if (! $tasks->has($parentKey)) { // 获取父任务的真实信息 $parentDetails = $this->getIssueDetails($parentKey); $parentSummary = $parentDetails ? $parentDetails->fields->summary : '父任务'; @@ -108,7 +109,7 @@ class JiraService $tasks->put($parentKey, [ 'key' => $parentKey, 'summary' => $parentSummary, - 'url' => $this->config['host'] . '/browse/' . $parentKey, + 'url' => $this->config['host'].'/browse/'.$parentKey, 'subtasks' => collect(), ]); } @@ -116,7 +117,7 @@ class JiraService $tasks[$parentKey]['subtasks']->put($issue->key, [ 'key' => $issue->key, 'summary' => $issue->fields->summary, - 'url' => $this->config['host'] . '/browse/' . $issue->key, + 'url' => $this->config['host'].'/browse/'.$issue->key, 'created' => $issue->fields->created ?? null, ]); } @@ -128,7 +129,7 @@ class JiraService { $username = $username ?: $this->config['default_user']; - if (!$username) { + if (! $username) { throw new \InvalidArgumentException('用户名不能为空'); } @@ -153,14 +154,14 @@ class JiraService 'status', 'project', 'issuetype', - 'created' + 'created', ]); - if (!empty($issues->issues)) { + if (! empty($issues->issues)) { return $this->organizeIssuesByProject($issues->issues); } } catch (JiraException $e) { - throw new \RuntimeException('获取未来任务失败: ' . $e->getMessage()); + throw new \RuntimeException('获取未来任务失败: '.$e->getMessage()); } return collect(); @@ -169,24 +170,21 @@ class JiraService /** * 生成 Markdown 格式的周报 */ - public function generateWeeklyReport(?string $username = null): string + public function generateWeeklyReport(?string $username = null, string $period = 'this_week'): string { $username = $username ?: $this->config['default_user']; - // 获取上周的工时记录 - $now = Carbon::now(); - $startOfWeek = $now->copy()->subWeek()->startOfWeek(); - $endOfWeek = $now->copy()->subWeek()->endOfWeek(); + $reportPeriod = $this->resolveWeeklyReportRange($period); - $workLogs = $this->getWorkLogs($username, $startOfWeek, $endOfWeek); + $workLogs = $this->getWorkLogs($username, $reportPeriod['start'], $reportPeriod['end']); $organizedTasks = $this->organizeTasksForReport($workLogs, $username); $nextWeekTasks = $this->getNextWeekTasks($username); - $markdown = "# 过去一周的任务\n\n"; + $markdown = "# {$reportPeriod['title']}\n\n"; if ($organizedTasks->isEmpty()) { - $markdown .= "本周暂无工时记录的任务。\n\n"; + $markdown .= "{$reportPeriod['empty_message']}\n\n"; } else { // 按Sprint分类的需求 if ($organizedTasks->has('sprints') && $organizedTasks['sprints']->isNotEmpty()) { @@ -311,6 +309,33 @@ class JiraService return $markdown; } + /** + * 解析周报统计周期 + * + * @return array{start: Carbon, end: Carbon, title: string, empty_message: string} + */ + private function resolveWeeklyReportRange(string $period): array + { + $normalizedPeriod = $period === 'this_week' ? 'this_week' : 'last_week'; + $now = Carbon::now(); + + if ($normalizedPeriod === 'this_week') { + return [ + 'start' => $now->copy()->startOfWeek(), + 'end' => $now->copy()->endOfDay(), + 'title' => '本周完成的任务', + 'empty_message' => '本周暂无工时记录的任务。', + ]; + } + + return [ + 'start' => $now->copy()->subWeek()->startOfWeek(), + 'end' => $now->copy()->subWeek()->endOfWeek(), + 'title' => '上周完成的任务', + 'empty_message' => '上周暂无工时记录的任务。', + ]; + } + /** * 获取指定日期范围内的工时记录 */ @@ -345,7 +370,7 @@ class JiraService 'customfield_14305', // 需求类型 ]); - if (!empty($issues->issues)) { + if (! empty($issues->issues)) { $workLogs = $this->extractWorkLogs($issues->issues, $username, $startDate, $endDate); if ($workLogs->isNotEmpty()) { @@ -353,8 +378,9 @@ class JiraService } } } catch (JiraException $e) { - throw new \RuntimeException('获取工时记录失败: ' . $e->getMessage()); + throw new \RuntimeException('获取工时记录失败: '.$e->getMessage()); } + // 如果所有查询都没有结果,返回空集合 return collect(); } @@ -377,7 +403,7 @@ class JiraService // 处理 author 可能是数组或对象的情况 $authorName = is_array($worklog->author) ? ($worklog->author['name'] ?? '') : ($worklog->author->name ?? ''); - if (!empty($authorName) && $authorName === $username && + if (! empty($authorName) && $authorName === $username && $worklogDate->between($startDate, $endDate)) { // 获取父任务信息 @@ -411,7 +437,7 @@ class JiraService 'project_key' => $issue->fields->project->key ?? '', 'issue_key' => $issue->key, 'issue_summary' => $issue->fields->summary ?? '', - 'issue_url' => $this->config['host'] . '/browse/' . $issue->key, + 'issue_url' => $this->config['host'].'/browse/'.$issue->key, 'issue_status' => $issue->fields->status->name ?? 'Unknown', 'issue_type' => $issue->fields->issuetype->name ?? 'Unknown', 'issue_created' => $issue->fields->created ?? null, @@ -464,7 +490,7 @@ class JiraService $sprintField = $issue->fields->customFields['customfield_10004']; // 处理数组情况 - if (is_array($sprintField) && !empty($sprintField)) { + if (is_array($sprintField) && ! empty($sprintField)) { $lastSprint = end($sprintField); if (is_string($lastSprint)) { // 解析Sprint字符串,格式通常为: com.atlassian.greenhopper.service.sprint.Sprint@xxx[name=十月中需求,...] @@ -486,13 +512,14 @@ class JiraService if (preg_match('/name=([^,\]]+)/', $sprintField, $matches)) { return $matches[1]; } + // 如果是纯文本,直接返回 return $sprintField; } } // 尝试从fixVersions获取版本信息作为备选 - if (isset($issue->fields->fixVersions) && is_array($issue->fields->fixVersions) && !empty($issue->fields->fixVersions)) { + if (isset($issue->fields->fixVersions) && is_array($issue->fields->fixVersions) && ! empty($issue->fields->fixVersions)) { return $issue->fields->fixVersions[0]->name ?? null; } @@ -526,7 +553,7 @@ class JiraService $stageValue = null; } - if ($stageValue && !empty($stageValue)) { + if ($stageValue && ! empty($stageValue)) { // 标准化阶段名称 if (str_contains($stageValue, 'SIT') || str_contains($stageValue, 'sit') || $stageValue === '测试阶段') { return 'SIT环境BUG'; @@ -537,8 +564,9 @@ class JiraService if (str_contains($stageValue, 'UAT') || str_contains($stageValue, 'uat')) { return 'UAT环境BUG'; } + // 如果不匹配标准格式,直接返回原值 - return $stageValue . 'BUG'; + return $stageValue.'BUG'; } } @@ -572,7 +600,7 @@ class JiraService // 处理对象类型 if (is_object($type) && isset($type->value)) { return $type->value; - } elseif (is_string($type) && !empty($type)) { + } elseif (is_string($type) && ! empty($type)) { return $type; } } @@ -601,7 +629,7 @@ class JiraService if (isset($issue->fields->customFields['customfield_10115'])) { $description = $issue->fields->customFields['customfield_10115']; - if (is_string($description) && !empty($description)) { + if (is_string($description) && ! empty($description)) { return $description; } } @@ -617,7 +645,7 @@ class JiraService // 从customfield_14305获取需求类型 if (isset($issue->fields->customFields['customfield_14305'])) { $type = $issue->fields->customFields['customfield_14305']; - if (is_array($type) && !empty($type)) { + if (is_array($type) && ! empty($type)) { $firstType = $type[0]; if (is_object($firstType) && isset($firstType->value)) { return $firstType->value; @@ -657,16 +685,8 @@ class JiraService */ private function extractDeveloper($issue): ?string { - // 从customfield_11000获取开发人 - if (isset($issue->fields->customFields['customfield_11000'])) { - $developer = $issue->fields->customFields['customfield_11000']; - - if (is_string($developer) && !empty($developer)) { - return $developer; - } - } - - return null; + // 从customfield_11000获取开发人(User 类型字段返回对象,兼容字符串/数组) + return $this->extractUserFieldName($issue, 'customfield_11000'); } /** @@ -674,13 +694,32 @@ class JiraService */ private function extractActualFixer($issue): ?string { - // 从customfield_11301获取实际修复人 - if (isset($issue->fields->customFields['customfield_11301'])) { - $fixer = $issue->fields->customFields['customfield_11301']; + // 从customfield_11301获取实际修复人(User 类型字段返回对象,兼容字符串/数组) + return $this->extractUserFieldName($issue, 'customfield_11301'); + } - if (is_string($fixer) && !empty($fixer)) { - return $fixer; - } + /** + * 通用 User 类型自定义字段解析:兼容对象、关联数组、字符串 + */ + private function extractUserFieldName($issue, string $fieldKey): ?string + { + if (! isset($issue->fields->customFields[$fieldKey])) { + return null; + } + + $value = $issue->fields->customFields[$fieldKey]; + + if (is_object($value)) { + return $value->name ?? $value->key ?? null; + } + + if (is_array($value)) { + // JIRA 用户字段以关联数组形式返回时 + return $value['name'] ?? $value['key'] ?? null; + } + + if (is_string($value) && $value !== '') { + return $value; } return null; @@ -695,6 +734,7 @@ class JiraService $summary = preg_replace('/!([^!]+\.(png|jpg|jpeg|gif|bmp))!/i', '', $summary); // 移除多余的空格和换行 $summary = preg_replace('/\s+/', ' ', $summary); + return trim($summary); } @@ -709,6 +749,9 @@ class JiraService '未开始', '需求已确认', '开发中', + 'Open', + 'To Do', + 'In Progress', '需求调研中', '需求已调研', '需求已评审', @@ -718,7 +761,7 @@ class JiraService ]; // 如果状态不在"未完成"列表中,则标记为已完成 - return !in_array($status, $incompleteStatuses, true); + return ! in_array($status, $incompleteStatuses, true); } /** @@ -775,13 +818,13 @@ class JiraService || ($actualFixer === $username); // 如果不是当前用户相关的Bug,跳过 - if (!$isUserRelated) { + if (! $isUserRelated) { continue; } // Bug按发现阶段分类 $stage = $workLog['bug_stage']; - if (!$organized['bugs']->has($stage)) { + if (! $organized['bugs']->has($stage)) { $organized['bugs']->put($stage, collect()); } @@ -804,15 +847,15 @@ class JiraService } elseif (($isStory || $isSubtask) && $workLog['sprint']) { // Story类型或子任务,且有Sprint的,按Sprint分类(需求) $sprintName = $workLog['sprint']; - if (!$organized['sprints']->has($sprintName)) { + if (! $organized['sprints']->has($sprintName)) { $organized['sprints']->put($sprintName, collect()); } $this->addTaskToSprintOrTaskList($organized['sprints'][$sprintName], $workLog); - } elseif ($isStory && !$workLog['sprint']) { + } elseif ($isStory && ! $workLog['sprint']) { // Story类型但没有Sprint的,放入需求分类 $this->addTaskToSprintOrTaskList($organized['stories'], $workLog); - } elseif ($isSubtask && !$workLog['sprint'] && $workLog['parent_task']) { + } elseif ($isSubtask && ! $workLog['sprint'] && $workLog['parent_task']) { // 子任务没有Sprint,检查父任务类型来决定分类 $parentKey = $workLog['parent_task']['key']; $parentDetails = $this->getIssueDetails($parentKey); @@ -846,7 +889,7 @@ class JiraService // 子任务 $parentKey = $workLog['parent_task']['key']; - if (!$taskList->has($parentKey)) { + if (! $taskList->has($parentKey)) { // 获取父任务的真实信息 $parentDetails = $this->getIssueDetails($parentKey); $parentSummary = $parentDetails ? $parentDetails->fields->summary : $workLog['parent_task']['summary']; @@ -855,7 +898,7 @@ class JiraService $taskList->put($parentKey, [ 'key' => $parentKey, 'summary' => $parentSummary, - 'url' => $this->config['host'] . '/browse/' . $parentKey, + 'url' => $this->config['host'].'/browse/'.$parentKey, 'status' => $parentStatus, 'subtasks' => collect(), ]); @@ -870,7 +913,7 @@ class JiraService ]); } else { // 主任务 - if (!$taskList->has($workLog['issue_key'])) { + if (! $taskList->has($workLog['issue_key'])) { $taskList->put($workLog['issue_key'], [ 'key' => $workLog['issue_key'], 'summary' => $workLog['issue_summary'], @@ -886,15 +929,15 @@ class JiraService * 获取下一个 release 版本 * 根据当前版本号,在 Jira 版本列表中找到下一个版本 * - * @param string $projectKey Jira 项目 key - * @param string|null $currentVersion 当前版本号(来自 master 分支的 version.txt) + * @param string $projectKey Jira 项目 key + * @param string|null $currentVersion 当前版本号(来自 master 分支的 version.txt) */ public function getUpcomingReleaseVersion(string $projectKey, ?string $currentVersion = null): ?array { try { $versions = $this->projectService->getVersions($projectKey); } catch (JiraException $e) { - throw new \RuntimeException('获取 release 版本失败: ' . $e->getMessage(), previous: $e); + throw new \RuntimeException('获取 release 版本失败: '.$e->getMessage(), previous: $e); } if (empty($versions)) { @@ -903,8 +946,8 @@ class JiraService // 按版本名称排序(假设版本号格式一致,如 1.0.0, 1.0.1, 1.1.0) $sortedVersions = collect($versions) - ->filter(fn($version) => !empty($version->name)) - ->sortBy(fn($version) => $version->name, SORT_NATURAL) + ->filter(fn ($version) => ! empty($version->name)) + ->sortBy(fn ($version) => $version->name, SORT_NATURAL) ->values(); if ($sortedVersions->isEmpty()) { @@ -914,17 +957,17 @@ class JiraService // 如果没有提供当前版本,返回第一个未发布的版本 if (empty($currentVersion)) { $candidate = $sortedVersions - ->filter(fn($version) => !($version->released ?? false)) + ->filter(fn ($version) => ! ($version->released ?? false)) ->first(); - if (!$candidate) { + if (! $candidate) { return null; } return [ 'version' => $candidate->name, 'description' => $candidate->description ?? null, - 'release_date' => !empty($candidate->releaseDate) + 'release_date' => ! empty($candidate->releaseDate) ? Carbon::parse($candidate->releaseDate)->toDateString() : null, ]; @@ -932,7 +975,7 @@ class JiraService // 找到当前版本在列表中的位置,返回下一个版本 $currentIndex = $sortedVersions->search( - fn($version) => $version->name === $currentVersion + fn ($version) => $version->name === $currentVersion ); // 如果找不到当前版本,尝试找到第一个大于当前版本的未发布版本 @@ -942,18 +985,19 @@ class JiraService if ($version->released ?? false) { return false; } + return version_compare($version->name, $currentVersion, '>'); }) ->first(); - if (!$candidate) { + if (! $candidate) { return null; } return [ 'version' => $candidate->name, 'description' => $candidate->description ?? null, - 'release_date' => !empty($candidate->releaseDate) + 'release_date' => ! empty($candidate->releaseDate) ? Carbon::parse($candidate->releaseDate)->toDateString() : null, ]; @@ -962,17 +1006,17 @@ class JiraService // 从当前版本的下一个开始,找到第一个未发布的版本 $candidate = $sortedVersions ->slice($currentIndex + 1) - ->filter(fn($version) => !($version->released ?? false)) + ->filter(fn ($version) => ! ($version->released ?? false)) ->first(); - if (!$candidate) { + if (! $candidate) { return null; } return [ 'version' => $candidate->name, 'description' => $candidate->description ?? null, - 'release_date' => !empty($candidate->releaseDate) + 'release_date' => ! empty($candidate->releaseDate) ? Carbon::parse($candidate->releaseDate)->toDateString() : null, ]; diff --git a/resources/js/components/jira/WeeklyReport.vue b/resources/js/components/jira/WeeklyReport.vue index 32e0e47..9fb75bd 100644 --- a/resources/js/components/jira/WeeklyReport.vue +++ b/resources/js/components/jira/WeeklyReport.vue @@ -3,12 +3,12 @@

生成周报

-

生成上周的工作周报

+

按周选择统计范围,生成对应周期的工作周报

-

生成上周周报

+

生成{{ selectedPeriodLabel }}周报

@@ -20,6 +20,17 @@ placeholder="输入 JIRA 用户名" >
+
+ + +
-
+
+
+

生成结果

+
+ 共 {{ stats.total }} 条,更新 {{ stats.update }} 条 +
+
+
+
+
+
{{ section.label }}
+ +
+ +
+
+
+

结果仅用于复制执行,请确认无误后使用

+
+
+ {{ copyStatus.message }} +
+
+ +

生成结果

@@ -143,6 +186,11 @@ export default { selectedTool: 'ob-external-id', inputText: '', outputSql: '', + splitOutputSql: { + sp: '', + ppCn: '', + ppUs: '' + }, querySql: '', loading: false, errors: [], @@ -161,7 +209,14 @@ export default { { value: 'ob-external-id', label: 'OB外部ID', - description: '每行输入 case_id 与 ob_id,系统会判断是否生成更新或插入 SQL。' + description: '每行输入 case_id 与 ob_id,系统会判断是否生成更新或插入 SQL。', + placeholder: '示例: X0X60F 17141\nC01008446046, 11894' + }, + { + value: 'new-factory-return-redelivery', + label: '新区工厂退库重新出库', + description: '每行输入加工单、病例号、正确运单,只读取前三列;根据 CRM 加工单关联地址国家拆分 PP-CN / PP-US SQL。', + placeholder: '示例: M20260508006905 225KF9 SF123456789\nM20260509005949 C01005934247 UPS987654321' } ] } @@ -169,12 +224,29 @@ export default { computed: { currentTool() { return this.toolOptions.find((tool) => tool.value === this.selectedTool) || this.toolOptions[0]; + }, + + showSplitOutput() { + return this.selectedTool === 'new-factory-return-redelivery'; + }, + + showQuerySql() { + return !this.showSplitOutput; + }, + + splitOutputSections() { + return [ + { key: 'sp', label: 'SP' }, + { key: 'ppCn', label: 'PP-CN' }, + { key: 'ppUs', label: 'PP-US' } + ]; } }, methods: { clearInput() { this.inputText = ''; this.outputSql = ''; + this.resetSplitOutputSql(); this.querySql = ''; this.errors = []; this.warnings = []; @@ -249,6 +321,7 @@ export default { async generateSql() { this.errors = []; this.outputSql = ''; + this.resetSplitOutputSql(); this.querySql = ''; this.warnings = []; this.resetCopyStatus(); @@ -259,6 +332,11 @@ export default { return; } + if (this.selectedTool === 'new-factory-return-redelivery') { + await this.generateNewFactoryReturnRedeliverySql(); + return; + } + this.errors = ['未识别的功能类型,请重新选择。']; }, @@ -321,6 +399,113 @@ export default { } }, + parseNewFactoryReturnRedeliveryInput() { + const lines = this.inputText.split(/\r?\n/); + const errors = []; + const rows = []; + + lines.forEach((line, index) => { + const trimmed = line.trim(); + if (!trimmed) { + return; + } + + const parts = trimmed.split(/[\s,,]+/).filter(Boolean); + if (parts.length < 3) { + errors.push(`第 ${index + 1} 行格式不正确,请提供加工单、病例号、正确运单三列`); + return; + } + + rows.push({ + productionCode: parts[0], + caseCode: parts[1], + expressNo: parts[2], + lineNumber: index + 1 + }); + }); + + if (rows.length === 0 && errors.length === 0) { + errors.push('请输入至少一行加工单、病例号、正确运单。'); + } + + return { + rows, + errors + }; + }, + + async generateNewFactoryReturnRedeliverySql() { + const { rows, errors } = this.parseNewFactoryReturnRedeliveryInput(); + if (errors.length) { + this.errors = errors; + return; + } + + this.loading = true; + + try { + const productionCodes = [...new Set(rows.map((row) => row.productionCode))]; + this.querySql = this.buildProductionCountriesSelect(productionCodes); + const response = await fetch('/api/sql-generator/production-countries/check', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content') + }, + body: JSON.stringify({ + production_codes: productionCodes + }) + }); + + const data = await response.json(); + + if (!response.ok || !data.success) { + this.errors = [data.message || '查询 CRM 加工单国家失败,请稍后重试。']; + return; + } + + const productionCountries = data.data.production_countries || {}; + const missingRows = rows.filter((row) => !productionCountries[row.productionCode] || productionCountries[row.productionCode].length === 0); + if (missingRows.length) { + this.errors = missingRows.map((row) => `第 ${row.lineNumber} 行加工单 ${row.productionCode} 未在 CRM 查询到地址国家`); + return; + } + + const spSql = []; + const ppCnSql = []; + const ppUsSql = []; + + rows.forEach((row) => { + const productionCode = this.escapeSqlValue(row.productionCode); + const expressNo = this.escapeSqlValue(row.expressNo); + const ppSql = this.isUsProductionCountry(productionCountries[row.productionCode]) ? ppUsSql : ppCnSql; + + spSql.push(`update case_deliveries set express_no = '${expressNo}' where production_code = '${productionCode}';`); + ppSql.push(`update delivery_records set express_no = '${expressNo}' where production_code = '${productionCode}';`); + ppSql.push(`update receives set express_no = '${expressNo}' where production_code = '${productionCode}';`); + }); + + const sections = [ + this.buildSqlSection('SP', spSql), + this.buildSqlSection('PP-CN', ppCnSql), + this.buildSqlSection('PP-US', ppUsSql) + ].filter(Boolean); + + this.stats.update = spSql.length + ppCnSql.length + ppUsSql.length; + this.stats.total = this.stats.update; + this.splitOutputSql = { + sp: spSql.join('\n'), + ppCn: ppCnSql.join('\n'), + ppUs: ppUsSql.join('\n') + }; + this.outputSql = sections.join('\n\n'); + } catch (error) { + this.errors = ['网络请求失败: ' + error.message]; + } finally { + this.loading = false; + } + }, + escapeSqlValue(value) { return String(value).replace(/'/g, "''"); }, @@ -334,6 +519,32 @@ export default { return `select * from case_extras where case_code in (${inValues})`; }, + buildProductionCountriesSelect(productionCodes) { + if (!productionCodes.length) { + return ''; + } + + const inValues = productionCodes.map((productionCode) => `'${this.escapeSqlValue(productionCode)}'`).join(', '); + return [ + 'select ep.name, epc.ea_case_id_c, epc.ea_businessorder_id_c, epc.ea_salesorder_id_c', + 'from ea_production ep', + 'join ea_production_cstm epc on ep.id = epc.id_c', + `where ep.deleted = 0 and ep.name in (${inValues})` + ].join('\n'); + }, + + isUsProductionCountry(countryCodes) { + return (countryCodes || []).some((countryCode) => ['US', 'GU', 'PR'].includes(String(countryCode).toUpperCase())); + }, + + buildSqlSection(title, sqlLines) { + if (!sqlLines.length) { + return ''; + } + + return [`-- ${title}`, ...sqlLines].join('\n'); + }, + buildDuplicateWarnings(duplicates) { return duplicates.map((duplicate) => { const lines = duplicate.lineNumbers.join('、'); @@ -348,6 +559,14 @@ export default { }; }, + resetSplitOutputSql() { + this.splitOutputSql = { + sp: '', + ppCn: '', + ppUs: '' + }; + }, + setCopyStatus(message, type) { this.copyStatus = { message, @@ -433,6 +652,10 @@ export default { await this.copyToClipboard(this.outputSql, '复制失败,请手动复制结果。', 'outputTextarea'); }, + async copySplitOutput(key) { + await this.copyToClipboard(this.splitOutputSql[key], '复制失败,请手动复制结果。'); + }, + async copyQuery() { await this.copyToClipboard(this.querySql, '复制失败,请手动复制查询SQL。', 'queryTextarea'); } diff --git a/routes/api.php b/routes/api.php index 91f294c..40ec2ce 100644 --- a/routes/api.php +++ b/routes/api.php @@ -33,6 +33,7 @@ Route::prefix('env')->group(function () { // SQL 生成器 API 路由 Route::prefix('sql-generator')->group(function () { Route::post('/ob-external-id/check', [SqlGeneratorController::class, 'checkObExternalId']); + Route::post('/production-countries/check', [SqlGeneratorController::class, 'checkProductionCountries']); }); // JIRA API路由 diff --git a/tests/Feature/SqlGeneratorTest.php b/tests/Feature/SqlGeneratorTest.php new file mode 100644 index 0000000..e7b9c55 --- /dev/null +++ b/tests/Feature/SqlGeneratorTest.php @@ -0,0 +1,133 @@ +postJson('/api/sql-generator/production-countries/check', []); + + $response->assertStatus(422); + $response->assertJson([ + 'success' => false, + 'message' => '请求参数验证失败', + ]); + } + + public function test_check_production_countries_returns_country_codes_from_crm(): void + { + $connection = Mockery::mock(); + DB::shouldReceive('connection') + ->times(2) + ->with('crmslave') + ->andReturn($connection); + + $productionBuilder = Mockery::mock(); + $productionBuilder->shouldReceive('join') + ->once() + ->with('ea_production_cstm as epc', 'ep.id', '=', 'epc.id_c') + ->andReturnSelf(); + $productionBuilder->shouldReceive('where') + ->once() + ->with('ep.deleted', 0) + ->andReturnSelf(); + $productionBuilder->shouldReceive('whereIn') + ->once() + ->with('ep.name', ['M20260508006905']) + ->andReturnSelf(); + $productionBuilder->shouldReceive('select') + ->once() + ->with([ + 'ep.name as production_code', + 'epc.ea_case_id_c', + 'epc.ea_businessorder_id_c', + 'epc.ea_salesorder_id_c', + ]) + ->andReturnSelf(); + $productionBuilder->shouldReceive('get') + ->once() + ->andReturn(collect([ + (object) [ + 'production_code' => 'M20260508006905', + 'ea_case_id_c' => '', + 'ea_businessorder_id_c' => '', + 'ea_salesorder_id_c' => 'sales-order-1', + ], + ])); + + $salesOrderBuilder = Mockery::mock(); + $salesOrderBuilder->shouldReceive('join') + ->once() + ->with('accounts_ea_salesorder_1_c as aes1c', Mockery::type('Closure')) + ->andReturnSelf(); + $salesOrderBuilder->shouldReceive('join') + ->once() + ->with('accounts as a_base', 'a_base.id', '=', 'aes1c.accounts_ea_salesorder_1accounts_ida') + ->andReturnSelf(); + $salesOrderBuilder->shouldReceive('join') + ->once() + ->with('accounts_cstm as ac', 'ac.id_c', '=', 'aes1c.accounts_ea_salesorder_1accounts_ida') + ->andReturnSelf(); + $salesOrderBuilder->shouldReceive('where') + ->once() + ->with('es.deleted', 0) + ->andReturnSelf(); + $salesOrderBuilder->shouldReceive('where') + ->once() + ->with('a_base.deleted', 0) + ->andReturnSelf(); + $salesOrderBuilder->shouldReceive('whereIn') + ->once() + ->with('es.id', ['sales-order-1']) + ->andReturnSelf(); + $salesOrderBuilder->shouldReceive('whereNotNull') + ->once() + ->with('ac.country_c') + ->andReturnSelf(); + $salesOrderBuilder->shouldReceive('select') + ->once() + ->with([ + 'es.id as entity_id', + 'ac.country_c as country', + 'ac.province_c as province', + ]) + ->andReturnSelf(); + $salesOrderBuilder->shouldReceive('get') + ->once() + ->andReturn(collect([ + (object) [ + 'entity_id' => 'sales-order-1', + 'country' => '840', + 'province' => '', + ], + ])); + + $connection->shouldReceive('table') + ->once() + ->with('ea_production as ep') + ->andReturn($productionBuilder); + $connection->shouldReceive('table') + ->once() + ->with('ea_salesorder as es') + ->andReturn($salesOrderBuilder); + + $response = $this->postJson('/api/sql-generator/production-countries/check', [ + 'production_codes' => ['M20260508006905'], + ]); + + $response->assertStatus(200); + $response->assertJson([ + 'success' => true, + 'data' => [ + 'production_countries' => [ + 'M20260508006905' => ['US'], + ], + ], + ]); + } +} diff --git a/tests/Unit/JiraServiceTest.php b/tests/Unit/JiraServiceTest.php index daac7de..9edd908 100644 --- a/tests/Unit/JiraServiceTest.php +++ b/tests/Unit/JiraServiceTest.php @@ -2,9 +2,10 @@ namespace Tests\Unit; -use Tests\TestCase; use App\Services\JiraService; +use Carbon\Carbon; use Illuminate\Support\Collection; +use Tests\TestCase; class JiraServiceTest extends TestCase { @@ -19,12 +20,19 @@ class JiraServiceTest extends TestCase 'jira.host' => 'https://test-jira.example.com', 'jira.username' => 'test-user', 'jira.password' => 'test-password', - 'jira.default_user' => 'test-user' + 'jira.default_user' => 'test-user', ]); $this->jiraService = app(JiraService::class); } + protected function tearDown(): void + { + Carbon::setTestNow(); + + parent::tearDown(); + } + public function test_is_task_completed_returns_false_for_incomplete_statuses() { $reflection = new \ReflectionClass($this->jiraService); @@ -37,7 +45,7 @@ class JiraServiceTest extends TestCase '需求已评审', 'In Progress', 'To Do', - 'Open' + 'Open', ]; foreach ($incompleteStatuses as $status) { @@ -59,7 +67,7 @@ class JiraServiceTest extends TestCase 'Closed', 'Resolved', '已完成', - 'Complete' + 'Complete', ]; foreach ($completeStatuses as $status) { @@ -76,10 +84,11 @@ class JiraServiceTest extends TestCase $method = $reflection->getMethod('organizeTasksForReport'); $emptyWorkLogs = collect(); - $result = $method->invoke($this->jiraService, $emptyWorkLogs); + $result = $method->invoke($this->jiraService, $emptyWorkLogs, 'test-user'); $this->assertInstanceOf(Collection::class, $result); $this->assertTrue($result->has('sprints')); + $this->assertTrue($result->has('stories')); $this->assertTrue($result->has('tasks')); $this->assertTrue($result->has('bugs')); } @@ -100,10 +109,10 @@ class JiraServiceTest extends TestCase 'bug_stage' => null, 'bug_type' => null, 'parent_task' => null, - ] + ], ]); - $result = $method->invoke($this->jiraService, $workLogs); + $result = $method->invoke($this->jiraService, $workLogs, 'test-user'); $this->assertTrue($result['sprints']->has('十月中需求')); $this->assertCount(1, $result['sprints']['十月中需求']); @@ -124,28 +133,62 @@ class JiraServiceTest extends TestCase 'sprint' => null, 'bug_stage' => 'SIT环境BUG', 'bug_type' => '需求未说明', + 'bug_description' => null, 'parent_task' => null, - ] + 'assignee' => 'test-user', + 'developer' => null, + 'actual_fixer' => null, + ], ]); - $result = $method->invoke($this->jiraService, $workLogs); + $result = $method->invoke($this->jiraService, $workLogs, 'test-user'); $this->assertTrue($result['bugs']->has('SIT环境BUG')); $this->assertCount(1, $result['bugs']['SIT环境BUG']); $this->assertEquals('需求未说明', $result['bugs']['SIT环境BUG'][0]['bug_type']); } + public function test_resolve_weekly_report_range_for_last_week() + { + Carbon::setTestNow('2026-04-02 10:00:00'); + + $reflection = new \ReflectionClass($this->jiraService); + $method = $reflection->getMethod('resolveWeeklyReportRange'); + + $result = $method->invoke($this->jiraService, 'last_week'); + + $this->assertEquals('2026-03-23 00:00:00', $result['start']->format('Y-m-d H:i:s')); + $this->assertEquals('2026-03-29 23:59:59', $result['end']->format('Y-m-d H:i:s')); + $this->assertEquals('上周完成的任务', $result['title']); + } + + public function test_resolve_weekly_report_range_for_this_week() + { + Carbon::setTestNow('2026-04-02 10:00:00'); + + $reflection = new \ReflectionClass($this->jiraService); + $method = $reflection->getMethod('resolveWeeklyReportRange'); + + $result = $method->invoke($this->jiraService, 'this_week'); + + $this->assertEquals('2026-03-30 00:00:00', $result['start']->format('Y-m-d H:i:s')); + $this->assertEquals('2026-04-02 23:59:59', $result['end']->format('Y-m-d H:i:s')); + $this->assertEquals('本周完成的任务', $result['title']); + } + public function test_extract_sprint_info_from_string() { $reflection = new \ReflectionClass($this->jiraService); $method = $reflection->getMethod('extractSprintInfo'); - $issue = (object)[ - 'fields' => (object)[ - 'customfield_10020' => [ - 'com.atlassian.greenhopper.service.sprint.Sprint@xxx[name=十月中需求,state=ACTIVE]' - ] - ] + $issue = (object) [ + 'fields' => (object) [ + 'customFields' => [ + 'customfield_10004' => [ + 'com.atlassian.greenhopper.service.sprint.Sprint@xxx[name=十月中需求,state=ACTIVE]', + ], + ], + ], ]; $result = $method->invoke($this->jiraService, $issue); @@ -157,10 +200,10 @@ class JiraServiceTest extends TestCase $reflection = new \ReflectionClass($this->jiraService); $method = $reflection->getMethod('extractBugStage'); - $issue = (object)[ - 'fields' => (object)[ - 'labels' => ['SIT', 'bug'] - ] + $issue = (object) [ + 'fields' => (object) [ + 'labels' => ['SIT', 'bug'], + ], ]; $result = $method->invoke($this->jiraService, $issue); @@ -172,13 +215,116 @@ class JiraServiceTest extends TestCase $reflection = new \ReflectionClass($this->jiraService); $method = $reflection->getMethod('extractBugType'); - $issue = (object)[ - 'fields' => (object)[ - 'labels' => ['需求未说明', 'bug'] - ] + $issue = (object) [ + 'fields' => (object) [ + 'labels' => ['需求未说明', 'bug'], + ], ]; $result = $method->invoke($this->jiraService, $issue); $this->assertEquals('需求未说明', $result); } + + public function test_extract_developer_from_user_object() + { + $reflection = new \ReflectionClass($this->jiraService); + $method = $reflection->getMethod('extractDeveloper'); + + $issue = (object) [ + 'fields' => (object) [ + 'customFields' => [ + 'customfield_11000' => (object) [ + 'name' => 'zhangsan', + 'displayName' => '张三', + 'emailAddress' => 'zhangsan@example.com', + ], + ], + ], + ]; + + $this->assertEquals('zhangsan', $method->invoke($this->jiraService, $issue)); + } + + public function test_extract_developer_from_associative_array() + { + $reflection = new \ReflectionClass($this->jiraService); + $method = $reflection->getMethod('extractDeveloper'); + + $issue = (object) [ + 'fields' => (object) [ + 'customFields' => [ + 'customfield_11000' => [ + 'name' => 'lisi', + 'displayName' => '李四', + ], + ], + ], + ]; + + $this->assertEquals('lisi', $method->invoke($this->jiraService, $issue)); + } + + public function test_extract_actual_fixer_from_user_object() + { + $reflection = new \ReflectionClass($this->jiraService); + $method = $reflection->getMethod('extractActualFixer'); + + $issue = (object) [ + 'fields' => (object) [ + 'customFields' => [ + 'customfield_11301' => (object) [ + 'name' => 'wangwu', + 'displayName' => '王五', + ], + ], + ], + ]; + + $this->assertEquals('wangwu', $method->invoke($this->jiraService, $issue)); + } + + public function test_extract_developer_returns_null_when_field_missing() + { + $reflection = new \ReflectionClass($this->jiraService); + $method = $reflection->getMethod('extractDeveloper'); + + $issue = (object) [ + 'fields' => (object) [ + 'customFields' => [], + ], + ]; + + $this->assertNull($method->invoke($this->jiraService, $issue)); + } + + public function test_organize_tasks_for_report_includes_bug_when_user_is_developer_only() + { + $reflection = new \ReflectionClass($this->jiraService); + $method = $reflection->getMethod('organizeTasksForReport'); + + // 模拟 WP-7158 场景:经办人是测试同学,开发人才是当前用户 + $workLogs = collect([ + [ + 'issue_key' => 'WP-7158', + 'issue_summary' => '生产 Bug 修复', + 'issue_url' => 'https://test-jira.example.com/browse/WP-7158', + 'issue_status' => 'Done', + 'issue_type' => 'Bug', + 'sprint' => null, + 'bug_stage' => '生产环境BUG', + 'bug_type' => '代码错误', + 'bug_description' => null, + 'parent_task' => null, + 'assignee' => 'tester-user', + 'developer' => 'test-user', + 'actual_fixer' => null, + ], + ]); + + $result = $method->invoke($this->jiraService, $workLogs, 'test-user'); + + $this->assertTrue($result['bugs']->has('生产环境BUG')); + $this->assertCount(1, $result['bugs']['生产环境BUG']); + $this->assertEquals('WP-7158', $result['bugs']['生产环境BUG'][0]['key']); + } }