diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/README.md b/README.md index 6df75d0..b9a2b53 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,6 @@ ### 后端 - Node.js + Express - SQLite + better-sqlite3-multiple-ciphers -- 加密存储(SQLCipher) ### 前端 - React 18 @@ -70,7 +69,7 @@ npm run dev ## 项目结构 ``` -trade-management-system/ +happy-export/ ├── backend/ # 后端 │ ├── db/ # 数据库 │ │ ├── schema.sql # 数据库结构 @@ -96,12 +95,30 @@ trade-management-system/ └── README.md ``` -## 数据库 +## 数据库与配置 -使用 SQLite + SQLCipher 加密存储: -- 数据库文件: `backend/data/trade.db` -- 自动初始化表结构 +SQLite 单文件存储: + +- 数据库文件:`backend/data/trade.db` +- 首次启动时自动建表 - 支持备份和迁移 +- 数据目录与上传文件已在 `.gitignore` 中排除,不会入库 + +> **已知限制:数据库当前未加密。** 驱动选用的是 `better-sqlite3-multiple-ciphers` +> (本身具备 SQLCipher 能力),但代码尚未设置加密密钥,`trade.db` 以明文存放。 +> 部署到共享主机或不可信环境前,请自行启用磁盘加密。 + +后端可识别的环境变量(全部有代码默认值,不设置也能跑): + +| 变量 | 默认值 | 说明 | +| --- | --- | --- | +| `DATA_DIR` | `backend/data` | 数据库与汇率缓存所在目录 | +| `PORT` | `4300` | 后端监听端口 | +| `CONTRACT_RETENTION_DAYS` | `7` | 合同导入原文件的保留天数 | +| `HAPPY_UPDATE_REPO` | `Drsakura/happy-export` | 在线更新检查所用的 GitHub 仓库 | + +> 注意:后端**没有引入 dotenv**,不会自动读取 `.env` 文件。 +> 请通过系统环境变量、启动脚本或容器编排来设置上述变量。 ## 开发计划 @@ -137,7 +154,11 @@ trade-management-system/ ## 许可证 -私有项目 +本项目采用 [GNU Affero 通用公共许可证 v3.0](LICENSE)(AGPL-3.0)。 + +你可以自由使用、修改和分发,但需要遵守 AGPL-3.0 的核心义务: +**如果你修改后的版本通过网络对外提供服务,必须向使用者公开你的完整源代码**, +并以同样的许可证授权。完整的条款见 [LICENSE](LICENSE)。 ## 作者 diff --git a/_index_backup.txt b/_index_backup.txt deleted file mode 100644 index 8b13789..0000000 --- a/_index_backup.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/backend/db/db.js b/backend/db/db.js index 444d55d..e8e2d18 100644 --- a/backend/db/db.js +++ b/backend/db/db.js @@ -16,9 +16,25 @@ const db = new Database(DB_PATH); // 启用外键约束 db.pragma('foreign_keys = ON'); -// 性能优化 +/* --------------------------------------------------------------------------- + * 并发与性能配置 —— 多人共用这套库的命脉,全部显式声明,不依赖驱动默认值。 + * + * journal_mode=WAL 读写互不阻塞。这是多用户能同时用的前提, + * 否则一个写操作会锁住全库所有的读。 + * synchronous=NORMAL 配合 WAL 的平衡点:断电最多丢最后一个事务, + * 但不会损坏数据库文件。 + * busy_timeout=5000 写冲突时排队等 5 秒再报错,而不是立刻抛 SQLITE_BUSY。 + * ⚠️ 这一项此前一直是 5000,但那是 better-sqlite3 的 + * 驱动默认值——一旦换驱动、或哪天默认值变了, + * 「多人同时保存」就会开始直接报错。必须显式写死。 + * 单次写事务只需 1–5ms,5 秒足够排完几十人的队列。 + * + * mmap_size 刻意不设:本库走 SQLCipher 加密路径,内存映射在加密库上 + * 历史有兼容性坑,而库只有几 MB,收益远小于风险。 + * --------------------------------------------------------------------------- */ db.pragma('journal_mode = WAL'); db.pragma('synchronous = NORMAL'); +db.pragma('busy_timeout = 5000'); // 初始化数据库结构 function initDatabase() { @@ -46,6 +62,190 @@ function ensureColumns() { db.exec("ALTER TABLE todos ADD COLUMN list TEXT NOT NULL DEFAULT 'schedule'"); console.log('✓ 迁移:todos.list 列已补齐'); } + + // 老数据库不会重新执行 schema.sql,这里只补新增表和新增列,不改动已有数据。 + db.exec(` + CREATE TABLE IF NOT EXISTS inquiries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + inquiry_no TEXT NOT NULL UNIQUE, + customer_id INTEGER NOT NULL REFERENCES customers(id), + inquiry_date TEXT NOT NULL, + title TEXT, status TEXT NOT NULL DEFAULT 'draft', currency TEXT DEFAULT 'USD', + source TEXT, notes TEXT, created_at TEXT NOT NULL, updated_at TEXT + ); + CREATE TABLE IF NOT EXISTS inquiry_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + inquiry_id INTEGER NOT NULL REFERENCES inquiries(id), sku TEXT NOT NULL, + product_name TEXT, supplier_id INTEGER REFERENCES suppliers(id), supplier_name TEXT, + purchase_price REAL NOT NULL DEFAULT 0, currency TEXT NOT NULL DEFAULT 'CNY', + exchange_rate REAL NOT NULL DEFAULT 1, markup_rate REAL NOT NULL DEFAULT 0, + markup_factor REAL NOT NULL DEFAULT 1, exw REAL NOT NULL DEFAULT 0, + quantity INTEGER NOT NULL DEFAULT 1, packaging_snapshot TEXT, product_snapshot TEXT, + quoted_at TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT + ); + CREATE TABLE IF NOT EXISTS follow_up_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + customer_id INTEGER NOT NULL REFERENCES customers(id), activity_type TEXT NOT NULL, + content TEXT NOT NULL, next_action TEXT, next_action_date TEXT, + contact_id INTEGER REFERENCES contacts(id), inquiry_id INTEGER REFERENCES inquiries(id), + created_at TEXT NOT NULL, updated_at TEXT + ); + CREATE TABLE IF NOT EXISTS product_packaging ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sku TEXT NOT NULL UNIQUE REFERENCES products(sku), inner_pack_qty INTEGER, carton_qty INTEGER, + carton_length REAL, carton_width REAL, carton_height REAL, carton_weight REAL, + unit_length REAL, unit_width REAL, unit_height REAL, unit_weight REAL, + packaging_material TEXT, notes TEXT, created_at TEXT NOT NULL, updated_at TEXT + ); + CREATE TABLE IF NOT EXISTS contract_import_jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + job_no TEXT NOT NULL UNIQUE, status TEXT NOT NULL DEFAULT 'queued', + file_count INTEGER NOT NULL DEFAULT 0, source_files TEXT, + parse_method TEXT NOT NULL DEFAULT 'rules', rule_version TEXT NOT NULL DEFAULT 'mvp-v1', + notes TEXT, created_at TEXT NOT NULL, updated_at TEXT, completed_at TEXT + ); + CREATE TABLE IF NOT EXISTS contract_import_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + job_id INTEGER NOT NULL REFERENCES contract_import_jobs(id), filename TEXT NOT NULL, + row_no INTEGER, raw_text TEXT, sku TEXT, product_name TEXT, purchase_price REAL, + currency TEXT, quantity INTEGER, status TEXT NOT NULL DEFAULT 'pending_review', + review_note TEXT, reviewed_by INTEGER REFERENCES users(id), reviewed_at TEXT, + created_at TEXT NOT NULL, updated_at TEXT + ); + CREATE INDEX IF NOT EXISTS idx_inquiries_customer ON inquiries(customer_id); + CREATE INDEX IF NOT EXISTS idx_inquiries_status_date ON inquiries(status, inquiry_date); + CREATE INDEX IF NOT EXISTS idx_inquiry_items_inquiry ON inquiry_items(inquiry_id); + CREATE INDEX IF NOT EXISTS idx_inquiry_items_sku ON inquiry_items(sku); + CREATE INDEX IF NOT EXISTS idx_follow_up_customer_date ON follow_up_records(customer_id, next_action_date); + CREATE INDEX IF NOT EXISTS idx_follow_up_inquiry ON follow_up_records(inquiry_id); + CREATE INDEX IF NOT EXISTS idx_product_packaging_sku ON product_packaging(sku); + CREATE INDEX IF NOT EXISTS idx_contract_import_jobs_status ON contract_import_jobs(status, created_at); + CREATE INDEX IF NOT EXISTS idx_contract_import_items_job ON contract_import_items(job_id, status); + + /* 组织与权限组(多用户阶段):老库补表。organizations 必须建在 users 补列之前, + 因为下面会给 users 加带外键的 org_id 列。 */ + CREATE TABLE IF NOT EXISTS organizations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, code TEXT, description TEXT, + created_at TEXT NOT NULL, updated_at TEXT + ); + CREATE TABLE IF NOT EXISTS permission_groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, description TEXT, is_system INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, updated_at TEXT + ); + CREATE TABLE IF NOT EXISTS permission_group_permissions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + group_id INTEGER NOT NULL REFERENCES permission_groups(id), + permission_key TEXT NOT NULL, UNIQUE (group_id, permission_key) + ); + CREATE TABLE IF NOT EXISTS role_permission_groups ( + role TEXT PRIMARY KEY, + group_id INTEGER NOT NULL REFERENCES permission_groups(id), updated_at TEXT + ); + CREATE INDEX IF NOT EXISTS idx_pg_permissions_group ON permission_group_permissions(group_id); + + /* 产品目录本地化(SKU 模块并入后):小推车 + 二进制存储。老库补表。 + 图片与合同原件单独成表,而不是给 product_images / contracts 加 BLOB 列 —— + 那两张表都用 SELECT * 查,二进制挂在同一行会让每次列表查询都把整张图读进内存。 */ + CREATE TABLE IF NOT EXISTS cart_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sku TEXT NOT NULL UNIQUE, qty INTEGER, note TEXT, + sort INTEGER DEFAULT 0, added_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS product_image_files ( + image_id INTEGER PRIMARY KEY, bytes BLOB NOT NULL, size INTEGER, stored_at TEXT + ); + CREATE TABLE IF NOT EXISTS contract_files ( + contract_id INTEGER PRIMARY KEY, filename TEXT, mime TEXT, size INTEGER, + sha256 TEXT, bytes BLOB NOT NULL, stored_at TEXT + ); + CREATE INDEX IF NOT EXISTS idx_cart_items_sort ON cart_items(sort); + `); + + const columnMigrations = { + inquiries: [ + ['inquiry_no', "TEXT NOT NULL DEFAULT ''"], ['customer_id', 'INTEGER'], ['inquiry_date', "TEXT NOT NULL DEFAULT ''"], + ['title', 'TEXT'], ['status', "TEXT NOT NULL DEFAULT 'draft'"], ['currency', "TEXT DEFAULT 'USD'"], ['source', 'TEXT'], + ['notes', 'TEXT'], ['created_at', "TEXT NOT NULL DEFAULT ''"], ['updated_at', 'TEXT'] + ], + inquiry_items: [ + ['inquiry_id', 'INTEGER'], ['sku', "TEXT NOT NULL DEFAULT ''"], ['product_name', 'TEXT'], ['supplier_id', 'INTEGER'], + ['supplier_name', 'TEXT'], ['purchase_price', 'REAL NOT NULL DEFAULT 0'], ['currency', "TEXT NOT NULL DEFAULT 'CNY'"], + ['exchange_rate', 'REAL NOT NULL DEFAULT 1'], ['markup_rate', 'REAL NOT NULL DEFAULT 0'], ['markup_factor', 'REAL NOT NULL DEFAULT 1'], + ['exw', 'REAL NOT NULL DEFAULT 0'], ['quantity', 'INTEGER NOT NULL DEFAULT 1'], ['packaging_snapshot', 'TEXT'], + ['product_snapshot', 'TEXT'], ['quoted_at', "TEXT NOT NULL DEFAULT ''"], ['created_at', "TEXT NOT NULL DEFAULT ''"], ['updated_at', 'TEXT'] + ], + follow_up_records: [ + ['customer_id', 'INTEGER'], ['activity_type', "TEXT NOT NULL DEFAULT 'note'"], ['content', "TEXT NOT NULL DEFAULT ''"], + ['next_action', 'TEXT'], ['next_action_date', 'TEXT'], ['contact_id', 'INTEGER'], ['inquiry_id', 'INTEGER'], + ['created_at', "TEXT NOT NULL DEFAULT ''"], ['updated_at', 'TEXT'] + ], + product_packaging: [ + ['sku', "TEXT NOT NULL DEFAULT ''"], ['inner_pack_qty', 'INTEGER'], ['carton_qty', 'INTEGER'], ['carton_length', 'REAL'], + ['carton_width', 'REAL'], ['carton_height', 'REAL'], ['carton_weight', 'REAL'], ['unit_length', 'REAL'], ['unit_width', 'REAL'], + ['unit_height', 'REAL'], ['unit_weight', 'REAL'], ['packaging_material', 'TEXT'], ['notes', 'TEXT'], + ['created_at', "TEXT NOT NULL DEFAULT ''"], ['updated_at', 'TEXT'] + ], + contract_import_jobs: [ + ['job_no', "TEXT NOT NULL DEFAULT ''"], ['status', "TEXT NOT NULL DEFAULT 'queued'"], ['file_count', 'INTEGER NOT NULL DEFAULT 0'], + ['source_files', 'TEXT'], ['parse_method', "TEXT NOT NULL DEFAULT 'rules'"], ['rule_version', "TEXT NOT NULL DEFAULT 'mvp-v1'"], + // column_map 存本次认列结果(字段→列号/表头/得分):解析不对时要能自证认了哪几列 + ['column_map', 'TEXT'], ['parse_source', "TEXT DEFAULT 'rules'"], + ['notes', 'TEXT'], ['created_at', "TEXT NOT NULL DEFAULT ''"], ['updated_at', 'TEXT'], ['completed_at', 'TEXT'] + ], + contract_import_items: [ + ['job_id', 'INTEGER'], ['filename', "TEXT NOT NULL DEFAULT ''"], ['row_no', 'INTEGER'], ['raw_text', 'TEXT'], ['sku', 'TEXT'], + ['product_name', 'TEXT'], ['spec', 'TEXT'], ['purchase_price', 'REAL'], ['currency', 'TEXT'], ['quantity', 'INTEGER'], + ['moq', 'INTEGER'], ['parse_source', "TEXT DEFAULT 'rules'"], + ['status', "TEXT NOT NULL DEFAULT 'pending_review'"], ['review_note', 'TEXT'], ['reviewed_by', 'INTEGER'], ['reviewed_at', 'TEXT'], + // 入库留痕:committed_at 记入库时间,product_sku 记实际落库货号(撞号会是 -COPY2) + ['committed_at', 'TEXT'], ['product_sku', 'TEXT'], + ['created_at', "TEXT NOT NULL DEFAULT ''"], ['updated_at', 'TEXT'] + ], + // 报价单明细的成本口径快照(老库补列) + quotation_items: [ + ['purchase_price', 'REAL'], ['exchange_rate', 'REAL'], ['markup_rate', 'REAL'] + ], + // 报价单条款结构化字段(老库补列)。payment_terms / delivery_terms 继续存可读文本。 + customer_quotations: [ + ['pi_no', 'TEXT'], ['payment_method', 'TEXT'], ['deposit_percent', 'REAL'], ['balance_percent', 'REAL'], + ['delivery_method', 'TEXT'], ['delivery_port', 'TEXT'], ['validity_days', 'INTEGER'], ['internal_notes', 'TEXT'] + ], + // 个人资料与组织归属(老库补列) + users: [ + ['position', 'TEXT'], ['phone', 'TEXT'], ['address', 'TEXT'], ['avatar', 'TEXT'], + ['org_id', 'INTEGER'], ['updated_at', 'TEXT'] + ], + // 产品目录本地化(SKU 模块并入):上传者 / 归属组织 / 撞号副本来源,以及调价人(老库补列) + products: [ + ['uploader', 'TEXT'], ['org_id', 'INTEGER'], ['duplicated_from', 'TEXT'] + ], + price_history: [ + ['changed_by', 'TEXT'] + ], + // 客户成交状态(新客开发页按 pending / won / lost 分栏)+ 归属与公海(老库补列) + customers: [ + ['deal_status', "TEXT DEFAULT 'pending'"], ['org_id', 'INTEGER'], + // created_by 单独记「谁建的」:公海被领取后 owner_id 会换人,创建人这一笔仍要留痕 + ['created_by', 'INTEGER'], + // pool_status:owned 名下有主 / public 已丢入公海(同组织可见可领) + ['pool_status', "TEXT DEFAULT 'owned'"], ['pool_at', 'TEXT'], ['pool_note', 'TEXT'] + ] + }; + for (const [table, columns] of Object.entries(columnMigrations)) { + const existing = new Set(db.pragma(`table_info(${table})`).map(column => column.name)); + for (const [name, definition] of columns) { + if (!existing.has(name)) db.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`); + } + } + /* settings 是键值表,权限点自动补齐的台账记在这里;老库万一没有也兜一下 */ + db.exec('CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)'); + /* 依赖「补列之后才存在」的索引,放在这里建 */ + db.exec('CREATE INDEX IF NOT EXISTS idx_users_org ON users(org_id)'); + /* 客户列表现在是「按归属过滤」,owner_id / pool_status 都得有索引 */ + db.exec('CREATE INDEX IF NOT EXISTS idx_customers_owner ON customers(owner_id)'); + db.exec('CREATE INDEX IF NOT EXISTS idx_customers_pool ON customers(pool_status)'); } ensureColumns(); @@ -75,4 +275,109 @@ function createDefaultAdmin() { createDefaultAdmin(); +// --------------------------------------------------------------------------- +// 组织与权限组的开箱数据:只在首次运行写入,之后一律以库里的为准。 +// 老库(已有 admin 用户)也会走这里补默认组织与默认权限组。 +// --------------------------------------------------------------------------- +function seedAccessControl() { + const now = new Date().toISOString(); + const { DEFAULT_PERMISSION_GROUPS } = require('../lib/permissions'); + + /* 默认组织:让存量用户立刻有归属,「同组织」口径从一开始就成立 */ + let org = db.prepare('SELECT id FROM organizations ORDER BY id LIMIT 1').get(); + if (!org) { + const result = db.prepare('INSERT INTO organizations (name, code, description, created_at) VALUES (?, ?, ?, ?)') + .run('happy出口通', 'HAPPY', '默认组织 —— 团队共享的产品/客户资源以此为单位', now); + org = { id: result.lastInsertRowid }; + console.log('✓ 默认组织已创建:happy出口通'); + } + db.prepare('UPDATE users SET org_id = ?, updated_at = ? WHERE org_id IS NULL').run(org.id, now); + + /* 默认权限组 + 角色指派 */ + const groupCount = db.prepare('SELECT COUNT(*) AS c FROM permission_groups').get().c; + if (groupCount === 0) { + const insertGroup = db.prepare('INSERT INTO permission_groups (name, description, is_system, created_at) VALUES (?, ?, ?, ?)'); + const insertPermission = db.prepare('INSERT OR IGNORE INTO permission_group_permissions (group_id, permission_key) VALUES (?, ?)'); + const assignRole = db.prepare('INSERT OR REPLACE INTO role_permission_groups (role, group_id, updated_at) VALUES (?, ?, ?)'); + for (const group of DEFAULT_PERMISSION_GROUPS) { + const id = insertGroup.run(group.name, group.description, group.is_system, now).lastInsertRowid; + for (const key of group.permissions) insertPermission.run(id, key); + for (const role of group.roles) assignRole.run(role, id, now); + } + console.log('✓ 默认权限组与角色指派已创建'); + } + + syncNewPermissions(); + adoptOrphanCustomers(org.id, now); +} + +/** + * 新增权限点的自动补齐。 + * + * 权限组只在「首次建库」时按 DEFAULT_PERMISSION_GROUPS 灌一遍,之后库里的勾选状态 + * 就是唯一事实 —— 所以后续版本新增的权限点,老库永远不会自动拿到(典型症状: + * 升级后某个功能对所有人都是「没有权限」)。这里把「全库都不认识的 key」 + * 按默认配置补进对应组,补过就记在 settings 台账里,之后绝不再碰: + * Wayne 手工取消勾选的动作不会被这段代码在下次启动时倒回去。 + */ +function syncNewPermissions() { + const { DEFAULT_PERMISSION_GROUPS, PERMISSION_KEYS } = require('../lib/permissions'); + const LEDGER = 'perm_autogrant'; + + let granted = []; + try { + granted = JSON.parse(db.prepare('SELECT value FROM settings WHERE key = ?').get(LEDGER)?.value || '[]'); + } catch { + granted = []; + } + const grantedSet = new Set(Array.isArray(granted) ? granted : []); + + const known = new Set( + db.prepare('SELECT DISTINCT permission_key FROM permission_group_permissions').all() + .map(row => row.permission_key) + ); + /* 本版本新增的 = 代码里有、但全库任何组都没拥有过、也没被自动补过 */ + const fresh = PERMISSION_KEYS.filter(key => !known.has(key) && !grantedSet.has(key)); + if (!fresh.length) return; + + const groupByName = db.prepare('SELECT id FROM permission_groups WHERE name = ?'); + const insert = db.prepare('INSERT OR IGNORE INTO permission_group_permissions (group_id, permission_key) VALUES (?, ?)'); + const added = []; + for (const group of DEFAULT_PERMISSION_GROUPS) { + const row = groupByName.get(group.name); + if (!row) continue; + for (const key of group.permissions) { + if (!fresh.includes(key)) continue; + insert.run(row.id, key); + added.push(`${group.name}:${key}`); + } + } + db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)') + .run(LEDGER, JSON.stringify([...grantedSet, ...fresh])); + if (added.length) console.log(`✓ 新增权限点已补齐:${added.join(', ')}`); +} + +/** + * 存量客户归属回填。 + * + * 「谁建的谁看得见」这条一旦生效,老库里 owner_id 为空的客户就会变成 + * 「除了最高级别管理账号谁都看不见」—— 升个级把老板的客户弄丢是不可接受的。 + * 所以统一认给最早的系统管理员,org 也一并补上。 + */ +function adoptOrphanCustomers(orgId, now) { + const orphans = db.prepare('SELECT COUNT(*) AS c FROM customers WHERE owner_id IS NULL').get().c; + if (!orphans) return; + const owner = db.prepare("SELECT id, org_id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1").get(); + if (!owner) return; + db.prepare(`UPDATE customers + SET owner_id = ?, + created_by = COALESCE(created_by, ?), + org_id = COALESCE(org_id, ?), + updated_at = COALESCE(updated_at, ?) + WHERE owner_id IS NULL`).run(owner.id, owner.id, owner.org_id || orgId, now); + console.log(`✓ 存量客户归属回填:${orphans} 条 → 用户 #${owner.id}`); +} + +seedAccessControl(); + module.exports = db; diff --git a/backend/db/schema.sql b/backend/db/schema.sql index 74b0b0d..8463772 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -51,6 +51,11 @@ CREATE TABLE IF NOT EXISTS products ( last_updated TEXT, source_contract TEXT, confidence TEXT DEFAULT 'high', + -- 上传者 / 归属组织 / 撞号副本来源:产品库是组织共享的,谁都能传自己那一版, + -- 「谁传的、复制自谁」必须可追,见 lib/skuCatalog.js 的 insertItem。 + uploader TEXT, + org_id INTEGER, + duplicated_from TEXT, created_at TEXT NOT NULL, updated_at TEXT ); @@ -110,6 +115,8 @@ CREATE TABLE IF NOT EXISTS price_history ( supplier_id INTEGER REFERENCES suppliers(id), source_contract TEXT, confidence TEXT, + -- 谁改的价:报价单里的采购价要能追到出处,和 insertItem 的手工留痕同源 + changed_by TEXT, changed_at TEXT NOT NULL ); @@ -142,6 +149,39 @@ CREATE TABLE IF NOT EXISTS product_images ( created_at TEXT NOT NULL ); +/* 小推车:产品页里挑出来的货号暂存区,出报价单前的中转台。 + 全局唯一一份(不按用户分车)—— 这是当前口径,改成分人分车要同时动前端。 */ +CREATE TABLE IF NOT EXISTS cart_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sku TEXT NOT NULL UNIQUE, + qty INTEGER, + note TEXT, + sort INTEGER DEFAULT 0, + added_at TEXT NOT NULL +); + +/* --------------------------------------------------------------------------- + * 二进制存储:图片与合同原件存进库,不摊在磁盘上。 + * 单独成表而不是给 product_images / contracts 加 BLOB 列 —— 那两张表都用 + * SELECT * 查,把二进制挂在同一行会让每次列表查询都把整张图读进内存。 + * --------------------------------------------------------------------------- */ +CREATE TABLE IF NOT EXISTS product_image_files ( + image_id INTEGER PRIMARY KEY REFERENCES product_images(id), + bytes BLOB NOT NULL, + size INTEGER, + stored_at TEXT +); + +CREATE TABLE IF NOT EXISTS contract_files ( + contract_id INTEGER PRIMARY KEY REFERENCES contracts(id), + filename TEXT, + mime TEXT, + size INTEGER, + sha256 TEXT, + bytes BLOB NOT NULL, + stored_at TEXT +); + -- ================== 客户管理(CRM) ================== CREATE TABLE IF NOT EXISTS customers ( @@ -156,7 +196,17 @@ CREATE TABLE IF NOT EXISTS customers ( email TEXT, rating TEXT, status TEXT DEFAULT 'active', + /* 开发结果:pending 未成交 / won 已成交 / lost 已流失(新客开发页按此分栏) */ + deal_status TEXT DEFAULT 'pending', + /* 归属:谁建的归谁,默认只有归属人看得见;创建人主动丢进公海才对同组织开放 */ owner_id INTEGER REFERENCES users(id), + /* created_by 单独记「谁建的」—— 公海被领取后 owner_id 会换人,创建人这一笔仍要留痕 */ + created_by INTEGER REFERENCES users(id), + org_id INTEGER REFERENCES organizations(id), + /* pool_status:owned 名下有主 / public 已丢入公海(同组织可见、可领取) */ + pool_status TEXT DEFAULT 'owned', + pool_at TEXT, + pool_note TEXT, source TEXT, tags TEXT, notes TEXT, @@ -275,10 +325,20 @@ CREATE TABLE IF NOT EXISTS customer_quotations ( quote_date TEXT NOT NULL, valid_until TEXT, currency TEXT DEFAULT 'USD', + -- 条款结构化字段:payment_terms / delivery_terms 仍保留可读文本,供老页面直接展示 + pi_no TEXT, + payment_method TEXT, + deposit_percent REAL, + balance_percent REAL, + delivery_method TEXT, + delivery_port TEXT, + validity_days INTEGER, payment_terms TEXT, delivery_terms TEXT, status TEXT DEFAULT 'draft', notes TEXT, + -- 系统备注:只给内部看,报价单对外不显示 + internal_notes TEXT, created_by INTEGER REFERENCES users(id), created_at TEXT NOT NULL, updated_at TEXT @@ -295,6 +355,10 @@ CREATE TABLE IF NOT EXISTS quotation_items ( unit_price REAL NOT NULL, total_price REAL NOT NULL, currency TEXT DEFAULT 'USD', + -- 成本口径快照:报价单发出去之后,供应商再调价也不能让这张单的历史毛利变样 + purchase_price REAL, + exchange_rate REAL, + markup_rate REAL, notes TEXT, created_at TEXT NOT NULL ); @@ -339,20 +403,63 @@ CREATE TABLE IF NOT EXISTS prospect_leads ( -- ================== 系统管理 ================== +-- 组织(团队):产品/客户的归属单位,「同组织的人均可参考」以它为边界。 +CREATE TABLE IF NOT EXISTS organizations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + code TEXT, + description TEXT, + created_at TEXT NOT NULL, + updated_at TEXT +); + CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, display_name TEXT, email TEXT, + /* role = 权限角色(董事长 / 总经理 / 销售经理 / 销售业务员 …),也是「角色 → 权限组」映射的键 */ role TEXT DEFAULT 'user', + /* position = 对外展示的职位称谓,纯文字,与角色解耦 */ + position TEXT, + phone TEXT, + address TEXT, + avatar TEXT, + org_id INTEGER REFERENCES organizations(id), is_active INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, + updated_at TEXT, last_login_at TEXT, failed_count INTEGER NOT NULL DEFAULT 0, locked_until TEXT ); +-- 权限组:一套命名的权限范围。角色被指派进某个组,即获得该组的全部权限点。 +CREATE TABLE IF NOT EXISTS permission_groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + description TEXT, + is_system INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT +); + +-- 权限组 → 权限点(permission_key 由后端 PERMISSIONS 清单定义) +CREATE TABLE IF NOT EXISTS permission_group_permissions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + group_id INTEGER NOT NULL REFERENCES permission_groups(id), + permission_key TEXT NOT NULL, + UNIQUE (group_id, permission_key) +); + +-- 角色 → 权限组(一个角色同时只属于一个组,改指派即换组) +CREATE TABLE IF NOT EXISTS role_permission_groups ( + role TEXT PRIMARY KEY, + group_id INTEGER NOT NULL REFERENCES permission_groups(id), + updated_at TEXT +); + CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id), @@ -401,3 +508,125 @@ CREATE INDEX IF NOT EXISTS idx_prospect_leads_run ON prospect_leads(run_id); CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id); CREATE INDEX IF NOT EXISTS idx_sessions_exp ON sessions(expires_at); CREATE INDEX IF NOT EXISTS idx_audit_at ON audit_log(at); + +-- ================== 询盘与报价主链路 ================== +CREATE TABLE IF NOT EXISTS inquiries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + inquiry_no TEXT NOT NULL UNIQUE, + customer_id INTEGER NOT NULL REFERENCES customers(id), + inquiry_date TEXT NOT NULL, + title TEXT, + status TEXT NOT NULL DEFAULT 'draft', + currency TEXT DEFAULT 'USD', + source TEXT, + notes TEXT, + created_at TEXT NOT NULL, + updated_at TEXT +); + +CREATE TABLE IF NOT EXISTS inquiry_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + inquiry_id INTEGER NOT NULL REFERENCES inquiries(id), + sku TEXT NOT NULL, + product_name TEXT, + supplier_id INTEGER REFERENCES suppliers(id), + supplier_name TEXT, + purchase_price REAL NOT NULL, + currency TEXT NOT NULL DEFAULT 'CNY', + exchange_rate REAL NOT NULL DEFAULT 1, + markup_rate REAL NOT NULL DEFAULT 0, + markup_factor REAL NOT NULL DEFAULT 1, + exw REAL NOT NULL, + quantity INTEGER NOT NULL DEFAULT 1, + packaging_snapshot TEXT, + product_snapshot TEXT, + quoted_at TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT +); + +CREATE TABLE IF NOT EXISTS follow_up_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + customer_id INTEGER NOT NULL REFERENCES customers(id), + activity_type TEXT NOT NULL, + content TEXT NOT NULL, + next_action TEXT, + next_action_date TEXT, + contact_id INTEGER REFERENCES contacts(id), + inquiry_id INTEGER REFERENCES inquiries(id), + created_at TEXT NOT NULL, + updated_at TEXT +); + +CREATE TABLE IF NOT EXISTS product_packaging ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sku TEXT NOT NULL UNIQUE REFERENCES products(sku), + inner_pack_qty INTEGER, + carton_qty INTEGER, + carton_length REAL, + carton_width REAL, + carton_height REAL, + carton_weight REAL, + unit_length REAL, + unit_width REAL, + unit_height REAL, + unit_weight REAL, + packaging_material TEXT, + notes TEXT, + created_at TEXT NOT NULL, + updated_at TEXT +); + +CREATE TABLE IF NOT EXISTS contract_import_jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + job_no TEXT NOT NULL UNIQUE, + status TEXT NOT NULL DEFAULT 'queued', + file_count INTEGER NOT NULL DEFAULT 0, + source_files TEXT, + parse_method TEXT NOT NULL DEFAULT 'rules', + rule_version TEXT NOT NULL DEFAULT 'mvp-v1', + -- column_map:本次认列结果(字段 → 列号/表头/得分),出问题时唯一能自证的现场 + column_map TEXT, + -- parse_source:这份结果是怎么来的 —— rules(字符规则)/ ai(模型解析) + parse_source TEXT DEFAULT 'rules', + notes TEXT, + created_at TEXT NOT NULL, + updated_at TEXT, + completed_at TEXT +); + +CREATE TABLE IF NOT EXISTS contract_import_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + job_id INTEGER NOT NULL REFERENCES contract_import_jobs(id), + filename TEXT NOT NULL, + row_no INTEGER, + raw_text TEXT, + sku TEXT, + product_name TEXT, + spec TEXT, + purchase_price REAL, + currency TEXT, + quantity INTEGER, + moq INTEGER, + status TEXT NOT NULL DEFAULT 'pending_review', + review_note TEXT, + parse_source TEXT DEFAULT 'rules', + reviewed_by INTEGER REFERENCES users(id), + reviewed_at TEXT, + -- committed_at / product_sku:入库留痕 —— 什么时候进的库、进成了哪个货号 + -- (撞号时实际落库的是 -COPY2,光看 sku 列看不出来) + committed_at TEXT, + product_sku TEXT, + created_at TEXT NOT NULL, + updated_at TEXT +); + +CREATE INDEX IF NOT EXISTS idx_inquiries_customer ON inquiries(customer_id); +CREATE INDEX IF NOT EXISTS idx_inquiries_status_date ON inquiries(status, inquiry_date); +CREATE INDEX IF NOT EXISTS idx_inquiry_items_inquiry ON inquiry_items(inquiry_id); +CREATE INDEX IF NOT EXISTS idx_inquiry_items_sku ON inquiry_items(sku); +CREATE INDEX IF NOT EXISTS idx_follow_up_customer_date ON follow_up_records(customer_id, next_action_date); +CREATE INDEX IF NOT EXISTS idx_follow_up_inquiry ON follow_up_records(inquiry_id); +CREATE INDEX IF NOT EXISTS idx_product_packaging_sku ON product_packaging(sku); +CREATE INDEX IF NOT EXISTS idx_contract_import_jobs_status ON contract_import_jobs(status, created_at); +CREATE INDEX IF NOT EXISTS idx_contract_import_items_job ON contract_import_items(job_id, status); diff --git a/backend/lib/aiPresets.js b/backend/lib/aiPresets.js new file mode 100644 index 0000000..15e2bfe --- /dev/null +++ b/backend/lib/aiPresets.js @@ -0,0 +1,195 @@ +/** + * 常用大模型接口预设(单一事实来源)。 + * + * 口径(Wayne 2026-09-15): + * - 别人部署这套系统后,应该「选一个预设 → 填 key → 测通 → 拉模型 → 分配用途」四步搞定, + * 而不是让他自己去查 base_url 和兼容模式; + * - 预设只是**便利清单**,不是白名单:所有字段都能手改(自定义预设就是空模板); + * - 模型清单同理:能拉就拉真实的,拉不到就用这里的推荐值兜底。 + * + * mode 的含义(= 界面上的「接口兼容模式」): + * openai 走 OpenAI 风格 `POST {base}/chat/completions` + `GET {base}/models` + * anthropic 走 Claude Messages API `POST {base}/messages` + `GET {base}/models` + */ + +/** + * 两种协议的 Base URL 写法**不一样**,界面上的示例要分别给(Wayne 2026-09-15): + * 本系统是「Base URL + 固定后缀」的拼法,所以用户填的东西必须写到能接后缀的那一层: + * openai → {base}/chat/completions、{base}/models + * anthropic → {base}/messages、{base}/models + * 最容易踩的坑是照抄 Anthropic SDK 文档里的 base_url —— SDK 会自己补 /v1,本系统不补。 + */ +const MODES = [ + { + value: 'openai', + label: 'OpenAI 兼容', + hint: '大多数国内厂商与本地推理服务都兼容这套路由', + base_placeholder: 'https://api.deepseek.com/v1', + base_suffix: '/chat/completions', + /* 界面上的示例说明:写清楚「填到哪一层」+「结尾长什么样因厂商而异」 */ + base_hint: '只填到服务根路径,系统会在后面自动拼 /chat/completions 与 /models。' + + '结尾不一定是 /v1 —— DeepSeek、Kimi 是 /v1,火山方舟是 /api/v3,智谱是 /api/paas/v4,照服务商文档填即可。', + base_examples: [ + { label: 'DeepSeek', url: 'https://api.deepseek.com/v1' }, + { label: '通义千问', url: 'https://dashscope.aliyuncs.com/compatible-mode/v1' }, + { label: '火山方舟', url: 'https://ark.cn-beijing.volces.com/api/v3' } + ] + }, + { + value: 'anthropic', + label: 'Anthropic Messages', + hint: 'Claude 原生协议的 /messages 路由', + base_placeholder: 'https://api.anthropic.com/v1', + base_suffix: '/messages', + /* ⚠️ 这条是本轮要讲清楚的区别:SDK 文档的 base_url 少了 /v1,直接抄会 404 */ + base_hint: '填的这一层后面会拼 /messages(不是 /chat/completions),所以必须带上 /v1:' + + '官方是 https://api.anthropic.com/v1,Kimi 是 https://api.moonshot.cn/anthropic/v1,' + + '智谱是 https://open.bigmodel.cn/api/anthropic/v1。' + + '注意各家 SDK 文档里的 base_url 通常写到 /anthropic 为止(由 SDK 补 /v1),本系统不补,别直接照抄。', + base_examples: [ + { label: 'Claude 官方', url: 'https://api.anthropic.com/v1' }, + { label: 'Kimi', url: 'https://api.moonshot.cn/anthropic/v1' }, + { label: '智谱 GLM', url: 'https://open.bigmodel.cn/api/anthropic/v1' } + ] + } +] + +/** 模型用途:一个用途 = 系统里一处需要智能能力的地方,各自可挂不同模型 */ +const PURPOSES = [ + { key: 'assistant_chat', label: '智能助手对话', hint: '右上角小皮的问答与指令理解', vision: false }, + { key: 'contract_parse', label: '合同智能解析', hint: '合同清洗中心抽取品名、价格、账期等字段', vision: false }, + { key: 'ocr', label: '图片 / 扫描件识别', hint: 'PDF 扫描件与图片资料,需要多模态模型', vision: true }, + { key: 'customer_profile', label: '客户画像分析', hint: '辅助判断客户跟进优先级', vision: false }, + { key: 'quote_suggest', label: '报价建议', hint: '基于历史成交价给出报价参考', vision: false } +] + +const PRESETS = [ + { + key: 'openai', + label: 'OpenAI', + base_url: 'https://api.openai.com/v1', + mode: 'openai', + key_hint: 'sk-...', + docs: 'https://platform.openai.com/api-keys', + note: '官方直连,国内网络通常需要自备代理', + models: ['gpt-4o', 'gpt-4o-mini', 'gpt-4.1', 'gpt-4.1-mini', 'o4-mini'] + }, + { + key: 'anthropic', + label: 'Anthropic Claude', + base_url: 'https://api.anthropic.com/v1', + mode: 'anthropic', + key_hint: 'sk-ant-...', + docs: 'https://console.anthropic.com/settings/keys', + note: '长文档与合同条款理解表现好', + models: ['claude-sonnet-4-5', 'claude-opus-4-1', 'claude-3-5-haiku-latest'] + }, + { + /* 和下面的 Kimi(OpenAI 兼容)是两个不同的地址,放一起就是为了让对比一眼可见 */ + key: 'moonshot-anthropic', + label: '月之暗面 Kimi · Anthropic 协议', + base_url: 'https://api.moonshot.cn/anthropic/v1', + mode: 'anthropic', + key_hint: 'sk-...', + docs: 'https://platform.moonshot.cn/console/api-keys', + note: '同一个 Kimi key,但地址与下面那条「Kimi」不一样:这条是 /anthropic/v1(Claude 协议),' + + '那条是 /v1(OpenAI 协议),填错会 404', + models: ['kimi-k2-0905-preview', 'moonshot-v1-128k'] + }, + { + key: 'zhipu-anthropic', + label: '智谱 GLM · Anthropic 协议', + base_url: 'https://open.bigmodel.cn/api/anthropic/v1', + mode: 'anthropic', + key_hint: 'xxxxx.xxxxx', + docs: 'https://open.bigmodel.cn/usercenter/apikeys', + note: '同一家的 Anthropic 兼容入口是 /api/anthropic/v1,OpenAI 兼容是 /api/paas/v4', + models: ['glm-4-plus', 'glm-4-air'] + }, + { + key: 'deepseek', + label: 'DeepSeek 深度求索', + base_url: 'https://api.deepseek.com/v1', + mode: 'openai', + key_hint: 'sk-...', + docs: 'https://platform.deepseek.com/api_keys', + note: '性价比高,中文业务理解稳定', + models: ['deepseek-chat', 'deepseek-reasoner'] + }, + { + key: 'moonshot', + label: '月之暗面 Kimi', + base_url: 'https://api.moonshot.cn/v1', + mode: 'openai', + key_hint: 'sk-...', + docs: 'https://platform.moonshot.cn/console/api-keys', + note: '长上下文,适合整份合同通读', + models: ['kimi-k2-0905-preview', 'moonshot-v1-32k', 'moonshot-v1-128k'] + }, + { + key: 'qwen', + label: '阿里通义千问(DashScope 兼容模式)', + base_url: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + mode: 'openai', + key_hint: 'sk-...', + docs: 'https://bailian.console.aliyun.com/', + note: 'qwen-vl 系列可用于图片识别', + models: ['qwen-max', 'qwen-plus', 'qwen-turbo', 'qwen-vl-max'] + }, + { + key: 'zhipu', + label: '智谱 GLM', + base_url: 'https://open.bigmodel.cn/api/paas/v4', + mode: 'openai', + key_hint: 'xxxxx.xxxxx', + docs: 'https://open.bigmodel.cn/usercenter/apikeys', + note: 'glm-4v 支持图片输入', + models: ['glm-4-plus', 'glm-4-air', 'glm-4-flash', 'glm-4v-plus'] + }, + { + key: 'doubao', + label: '火山方舟(豆包)', + base_url: 'https://ark.cn-beijing.volces.com/api/v3', + mode: 'openai', + key_hint: '火山方舟 API Key', + docs: 'https://console.volcengine.com/ark', + note: '模型名要填方舟上的「推理接入点 ID」(ep-开头),或直接填模型名', + models: [] + }, + { + key: 'openrouter', + label: 'OpenRouter(聚合网关)', + base_url: 'https://openrouter.ai/api/v1', + mode: 'openai', + key_hint: 'sk-or-...', + docs: 'https://openrouter.ai/keys', + note: '一个 key 打通多家模型,适合先试后定', + models: [] + }, + { + key: 'ollama', + label: '本地 Ollama', + base_url: 'http://127.0.0.1:11434/v1', + mode: 'openai', + key_hint: '本地服务通常留空', + docs: 'https://ollama.com/download', + note: '完全离线,数据不出本机;密钥可留空', + needs_key: false, + models: ['qwen2.5:7b', 'qwen2.5:14b', 'llama3.1:8b', 'deepseek-r1:7b'] + }, + { + key: 'custom', + label: '自定义 / 私有部署', + base_url: '', + mode: 'openai', + key_hint: '按你的网关要求填写', + docs: '', + note: '任何兼容 OpenAI 或 Anthropic 协议的网关都可以接', + models: [] + } +] + +const PRESET_MAP = new Map(PRESETS.map(item => [item.key, item])); +const PURPOSE_KEYS = PURPOSES.map(item => item.key); + +module.exports = { MODES, PRESETS, PRESET_MAP, PURPOSES, PURPOSE_KEYS }; diff --git a/backend/lib/aiProviders.js b/backend/lib/aiProviders.js new file mode 100644 index 0000000..152d3dc --- /dev/null +++ b/backend/lib/aiProviders.js @@ -0,0 +1,487 @@ +/** + * 多模型接入:模型服务商的存取、连通性测试、模型清单拉取与按用途调用。 + * + * 存储(settings 键值表,跟权限点台账同一张表): + * ai.providers JSON 数组,一家服务商一条 + * ai.assignments JSON 对象,用途 → { provider_id, model } + * + * 安全口径: + * - api_key 明文存在本机 SQLite 里,**永远不出现在任何 HTTP 响应中**(一律打码); + * - 上游返回的错误文本会先过一遍脱敏,避免把 key 回显给浏览器。 + * + * 这里只依赖 Node 内置 fetch(Node 18+),不引第三方 SDK —— 各家协议就两种, + * 引 SDK 反而会让「换个私有网关」变得麻烦。 + */ + +const db = require('../db/db'); +const { PRESET_MAP, PURPOSE_KEYS } = require('./aiPresets'); + +const KEY_PROVIDERS = 'ai.providers'; +const KEY_ASSIGNMENTS = 'ai.assignments'; + +const TEST_TIMEOUT_MS = 12000; +const CHAT_TIMEOUT_MS = 90000; + +/* ------------------------------------------------------------------ 存取 */ + +function readJson(key, fallback) { + try { + const raw = db.prepare('SELECT value FROM settings WHERE key = ?').get(key)?.value; + if (!raw) return fallback; + const parsed = JSON.parse(raw); + return parsed ?? fallback; + } catch { + return fallback; + } +} + +function writeJson(key, value) { + db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)').run(key, JSON.stringify(value)); +} + +function listProviders() { + const list = readJson(KEY_PROVIDERS, []); + return Array.isArray(list) ? list : []; +} + +function saveProviders(list) { + writeJson(KEY_PROVIDERS, list); +} + +function getProvider(id) { + return listProviders().find(item => item.id === String(id)) || null; +} + +function listAssignments() { + const map = readJson(KEY_ASSIGNMENTS, {}); + return map && typeof map === 'object' && !Array.isArray(map) ? map : {}; +} + +function saveAssignments(map) { + writeJson(KEY_ASSIGNMENTS, map); +} + +/* ------------------------------------------------------------------ 打码 */ + +function maskKey(key) { + const value = String(key || ''); + if (!value) return ''; + if (value.length <= 8) return '••••'; + return `${value.slice(0, 4)}••••${value.slice(-4)}`; +} + +/** 把上游报错里的密钥抹掉再往外抛 —— 有些网关会把请求头原样回显 */ +function sanitize(text, key) { + let out = String(text || ''); + if (key) out = out.split(String(key)).join('••••'); + return out.slice(0, 600); +} + +/** 对外输出:密钥只给「有没有」和「长什么样」,绝不给原值 */ +function publicProvider(provider) { + const preset = PRESET_MAP.get(provider.preset) || null; + return { + id: provider.id, + name: provider.name, + preset: provider.preset, + preset_label: preset?.label || '自定义', + base_url: provider.base_url, + mode: provider.mode, + models: Array.isArray(provider.models) ? provider.models : [], + enabled: provider.enabled !== false, + note: provider.note || '', + has_key: Boolean(provider.api_key), + key_masked: maskKey(provider.api_key), + last_test: provider.last_test || null, + created_at: provider.created_at || null, + updated_at: provider.updated_at || null + }; +} + +/* ------------------------------------------------------------------ 写入 */ + +function badRequest(message) { + return Object.assign(new Error(message), { statusCode: 400 }); +} + +function normalizeBaseUrl(input) { + const value = String(input || '').trim().replace(/\/+$/, ''); + if (!value) throw badRequest('接口地址不能为空'); + let url; + try { + url = new URL(value); + } catch { + throw badRequest('接口地址格式不正确,需要以 http:// 或 https:// 开头'); + } + if (!/^https?:$/.test(url.protocol)) throw badRequest('接口地址只支持 http 与 https'); + return value; +} + +function normalizeMode(input) { + const value = String(input || 'openai').trim(); + if (!['openai', 'anthropic'].includes(value)) throw badRequest('接口兼容模式只能是 openai 或 anthropic'); + return value; +} + +function normalizeModels(input) { + if (input === undefined) return undefined; + if (!Array.isArray(input)) throw badRequest('模型清单格式不正确'); + return [...new Set(input.map(item => String(item).trim()).filter(Boolean))].slice(0, 200); +} + +/** + * 新建 / 更新一家服务商。 + * api_key 的三种输入: + * 省略或传 undefined → 保持原值(编辑时前端不回传明文) + * 传空串 → 清空密钥(本地 Ollama 这类不需要密钥) + * 传新值 → 覆盖 + */ +function upsertProvider(input, id) { + const list = listProviders(); + const now = new Date().toISOString(); + const existing = id ? list.find(item => item.id === String(id)) : null; + if (id && !existing) throw badRequest('该模型接入不存在'); + + const presetKey = String(input.preset || existing?.preset || 'custom').trim(); + const preset = PRESET_MAP.get(presetKey) || null; + const mode = normalizeMode(input.mode || existing?.mode || preset?.mode || 'openai'); + const baseUrl = input.base_url === undefined && !existing + ? normalizeBaseUrl(preset?.base_url) + : normalizeBaseUrl(input.base_url === undefined ? existing.base_url : input.base_url); + + const name = String(input.name ?? existing?.name ?? preset?.label ?? '未命名接入').trim(); + if (!name) throw badRequest('接入名称不能为空'); + if (name.length > 40) throw badRequest('接入名称不能超过 40 个字'); + + let apiKey = existing?.api_key || ''; + if (input.api_key !== undefined) { + const value = String(input.api_key ?? '').trim(); + /* 前端拿到的是打码值,万一被原样回传就当没改 */ + apiKey = value.includes('••••') ? apiKey : value; + } + + const models = normalizeModels(input.models); + + const next = { + id: existing?.id || `p_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`, + name, + preset: preset?.key || 'custom', + base_url: baseUrl, + mode, + api_key: apiKey, + models: models === undefined ? (existing?.models || preset?.models || []) : models, + enabled: input.enabled === undefined ? (existing?.enabled !== false) : Boolean(input.enabled), + note: String(input.note ?? existing?.note ?? '').trim().slice(0, 200), + last_test: existing?.last_test || null, + created_at: existing?.created_at || now, + updated_at: now + }; + + const nextList = existing + ? list.map(item => (item.id === existing.id ? next : item)) + : [...list, next]; + saveProviders(nextList); + return next; +} + +function removeProvider(id) { + const list = listProviders(); + const hit = list.find(item => item.id === String(id)); + if (!hit) throw badRequest('该模型接入不存在'); + saveProviders(list.filter(item => item.id !== hit.id)); + /* 引用它的用途分配一并清掉,避免留下指向空气的配置 */ + const assignments = listAssignments(); + let touched = 0; + for (const purpose of Object.keys(assignments)) { + if (assignments[purpose]?.provider_id === hit.id) { + delete assignments[purpose]; + touched += 1; + } + } + if (touched) saveAssignments(assignments); + return { removed: publicProvider(hit), cleared_assignments: touched }; +} + +function rememberTest(id, result) { + const list = listProviders(); + const next = list.map(item => (item.id === String(id) + ? { ...item, last_test: result, updated_at: new Date().toISOString() } + : item)); + saveProviders(next); +} + +/* ------------------------------------------------------------------ 调用 */ + +/** 统一的 HTTP 出口:超时、错误文本脱敏、JSON 解析兜底都在这里 */ +async function requestJson(url, { method = 'POST', headers = {}, body, timeoutMs = TEST_TIMEOUT_MS, apiKey = '' } = {}) { + const started = Date.now(); + let response; + try { + response = await fetch(url, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + signal: AbortSignal.timeout(timeoutMs) + }); + } catch (error) { + const reason = error?.name === 'TimeoutError' + ? `请求超时(${Math.round(timeoutMs / 1000)} 秒)` + : `无法连接:${sanitize(error?.cause?.code || error?.message || '网络错误', apiKey)}`; + return { ok: false, status: 0, latencyMs: Date.now() - started, error: reason }; + } + + const latencyMs = Date.now() - started; + const text = await response.text().catch(() => ''); + let data = null; + try { + data = text ? JSON.parse(text) : null; + } catch { + data = null; + } + + if (!response.ok) { + const upstream = data?.error?.message || data?.message || data?.error || text || `HTTP ${response.status}`; + return { + ok: false, + status: response.status, + latencyMs, + error: sanitize(typeof upstream === 'string' ? upstream : JSON.stringify(upstream), apiKey), + data + }; + } + return { ok: true, status: response.status, latencyMs, data, text }; +} + +function buildHeaders(provider, extra = {}) { + const headers = { 'content-type': 'application/json', ...extra }; + const key = provider.api_key; + if (provider.mode === 'anthropic') { + headers['anthropic-version'] = '2023-06-01'; + if (key) headers['x-api-key'] = key; + } else if (key) { + headers.authorization = `Bearer ${key}`; + } + return headers; +} + +/** 探测用的最小对话请求:一条 user 消息、只要 1 个 token */ +function probeBody(provider, model) { + if (provider.mode === 'anthropic') { + return { model, max_tokens: 16, messages: [{ role: 'user', content: 'ping' }] }; + } + return { model, messages: [{ role: 'user', content: 'ping' }], max_tokens: 16, temperature: 0, stream: false }; +} + +function chatPath(provider) { + return provider.mode === 'anthropic' + ? `${provider.base_url}/messages` + : `${provider.base_url}/chat/completions`; +} + +/** 从各家返回体里把正文抠出来 */ +function extractChatText(provider, data) { + if (!data) return ''; + if (provider.mode === 'anthropic') { + const blocks = Array.isArray(data.content) ? data.content : []; + return blocks.filter(block => block?.type === 'text').map(block => block.text).join('').trim(); + } + const message = data.choices?.[0]?.message; + if (!message) return ''; + if (typeof message.content === 'string') return message.content.trim(); + /* 有些网关把 content 拆成数组(含 reasoning 之类),这里只取文本段 */ + if (Array.isArray(message.content)) { + return message.content.filter(part => part?.type === 'text' || typeof part === 'string') + .map(part => (typeof part === 'string' ? part : part.text)).join('').trim(); + } + return ''; +} + +/** + * 连通性测试。 + * 策略:优先发一条最小对话(最能说明「这个 key 能不能真的用」); + * 若该模型不可用再退一步只拉模型清单。 + */ +async function testProvider(provider) { + const model = (provider.models || [])[0]; + if (!model) { + const models = await fetchModels(provider); + if (!models.ok) return { ok: false, at: new Date().toISOString(), latency_ms: models.latencyMs, detail: models.error, mode: provider.mode }; + return { + ok: true, + at: new Date().toISOString(), + latency_ms: models.latencyMs, + detail: `接口可访问,读到 ${models.models.length} 个模型(未配置模型名,跳过对话测试)`, + mode: provider.mode + }; + } + + const result = await requestJson(chatPath(provider), { + method: 'POST', + headers: buildHeaders(provider), + body: probeBody(provider, model), + timeoutMs: TEST_TIMEOUT_MS, + apiKey: provider.api_key + }); + + if (!result.ok) { + return { + ok: false, + at: new Date().toISOString(), + latency_ms: result.latencyMs, + detail: `模型 ${model} 调用失败:${result.error}`, + mode: provider.mode, + status: result.status + }; + } + const text = extractChatText(provider, result.data); + return { + ok: true, + at: new Date().toISOString(), + latency_ms: result.latencyMs, + detail: `模型 ${model} 响应正常${text ? `(返回 ${text.slice(0, 20)})` : ''}`, + mode: provider.mode, + model + }; +} + +/** 拉模型清单;协议不支持或没权限时回退到预设里的推荐清单 */ +async function fetchModels(provider) { + const result = await requestJson(`${provider.base_url}/models`, { + method: 'GET', + headers: buildHeaders(provider), + timeoutMs: TEST_TIMEOUT_MS, + apiKey: provider.api_key + }); + + const preset = PRESET_MAP.get(provider.preset); + if (!result.ok) { + const fallback = preset?.models || []; + if (fallback.length) { + return { + ok: true, + source: 'preset', + latencyMs: result.latencyMs, + models: fallback, + detail: `该服务未开放模型列表接口(${result.error}),已回退到预设推荐清单` + }; + } + return { ok: false, source: 'none', latencyMs: result.latencyMs, models: [], error: result.error }; + } + + const rows = Array.isArray(result.data?.data) ? result.data.data + : Array.isArray(result.data?.models) ? result.data.models + : Array.isArray(result.data) ? result.data : []; + const models = [...new Set(rows.map(row => String(row?.id || row?.name || row || '').trim()).filter(Boolean))]; + if (models.length) return { ok: true, source: 'api', latencyMs: result.latencyMs, models, detail: `读取到 ${models.length} 个模型` }; + + const fallback = preset?.models || []; + return { + ok: fallback.length > 0, + source: fallback.length ? 'preset' : 'none', + latencyMs: result.latencyMs, + models: fallback, + detail: fallback.length ? '接口未返回模型清单,已回退到预设推荐' : '接口未返回任何模型', + error: fallback.length ? undefined : '接口未返回任何模型' + }; +} + +/** 按用途找「用哪家、哪个模型」 */ +function resolveAssignment(purpose) { + const assignment = listAssignments()[purpose]; + if (!assignment?.provider_id || !assignment?.model) return null; + const provider = getProvider(assignment.provider_id); + if (!provider || provider.enabled === false) return null; + return { provider, model: assignment.model }; +} + +/** + * 真正的对话调用(按用途自动选模型)。 + * 未配置时返回 null,调用方据此回退到本地规则逻辑。 + */ +async function chatByPurpose(purpose, messages, options = {}) { + const resolved = resolveAssignment(purpose); + if (!resolved) return null; + const { provider, model } = resolved; + + const result = await requestJson(chatPath(provider), { + method: 'POST', + headers: buildHeaders(provider), + body: { + model, + messages: provider.mode === 'anthropic' + ? messages.filter(message => message.role !== 'system') + : messages, + ...(provider.mode === 'anthropic' + ? { + max_tokens: options.maxTokens || 1024, + ...(messages.find(message => message.role === 'system') + ? { system: messages.find(message => message.role === 'system').content } + : {}) + } + : { + max_tokens: options.maxTokens || 1024, + temperature: options.temperature === undefined ? 0.3 : options.temperature, + stream: false + }) + }, + timeoutMs: options.timeoutMs || CHAT_TIMEOUT_MS, + apiKey: provider.api_key + }); + + if (!result.ok) { + return { + ok: false, + provider: provider.name, + provider_id: provider.id, + model, + error: result.error, + status: result.status + }; + } + return { + ok: true, + provider: provider.name, + provider_id: provider.id, + model, + text: extractChatText(provider, result.data), + latency_ms: result.latencyMs, + usage: result.data?.usage || null + }; +} + +/** 概览:给界面显示「接了几家、分配了几个用途」 */ +function overview() { + const providers = listProviders(); + const assignments = listAssignments(); + const assigned = PURPOSE_KEYS.filter(key => { + const item = assignments[key]; + return item?.provider_id && item?.model && providers.some(p => p.id === item.provider_id && p.enabled !== false); + }); + const healthy = providers.filter(p => p.last_test?.ok); + return { + provider_count: providers.length, + enabled_count: providers.filter(p => p.enabled !== false).length, + healthy_count: healthy.length, + assigned_purposes: assigned, + last_test_at: healthy.map(p => p.last_test?.at).sort().pop() || null + }; +} + +module.exports = { + MASK_PLACEHOLDER: '••••', + listProviders, + getProvider, + publicProvider, + upsertProvider, + removeProvider, + listAssignments, + saveAssignments, + resolveAssignment, + testProvider, + fetchModels, + chatByPurpose, + rememberTest, + maskKey, + overview, + PURPOSE_KEYS +}; diff --git a/backend/lib/auth.js b/backend/lib/auth.js new file mode 100644 index 0000000..de7c952 --- /dev/null +++ b/backend/lib/auth.js @@ -0,0 +1,227 @@ +/** + * 鉴权与会话(多用户阶段)。 + * + * 设计取舍: + * - 密码哈希沿用 db.js 建默认管理员时的格式 `scrypt$salt$hash`,不引第三方依赖 + * (本机没有 bcrypt,也不想去碰原生模块编译),Node 内置 crypto.scryptSync 足够。 + * - 会话复用已有的 sessions 表,浏览器侧只放一个 HttpOnly cookie(happy_sid), + * 不把用户信息塞进 token,避免改资料后前端还拿着旧值。 + * - 权限一律「角色 → 权限组 → 权限点」三级推导,用户表不直接存权限。 + */ +const crypto = require('crypto'); +const db = require('../db/db'); +const { PERMISSION_KEYS } = require('./permissions'); + +const COOKIE_NAME = 'happy_sid'; +const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; +const MAX_FAILED = 5; +const LOCK_MS = 15 * 60 * 1000; + +/* ---------------------------------------------------------------- 密码 */ + +function hashPassword(plain) { + const salt = crypto.randomBytes(16).toString('hex'); + const hash = crypto.scryptSync(String(plain), salt, 64).toString('hex'); + return `scrypt$${salt}$${hash}`; +} + +function verifyPassword(plain, stored) { + const parts = String(stored || '').split('$'); + if (parts.length !== 3 || parts[0] !== 'scrypt') return false; + const [, salt, expected] = parts; + let actual; + try { + actual = crypto.scryptSync(String(plain), salt, 64).toString('hex'); + } catch { + return false; + } + const a = Buffer.from(actual, 'hex'); + const b = Buffer.from(expected, 'hex'); + if (a.length !== b.length) return false; + return crypto.timingSafeEqual(a, b); +} + +/* ---------------------------------------------------------------- cookie */ + +function parseCookies(header) { + const out = {}; + if (!header) return out; + for (const piece of String(header).split(';')) { + const index = piece.indexOf('='); + if (index < 0) continue; + const key = piece.slice(0, index).trim(); + if (!key) continue; + try { + out[key] = decodeURIComponent(piece.slice(index + 1).trim()); + } catch { + out[key] = piece.slice(index + 1).trim(); + } + } + return out; +} + +function setSessionCookie(res, id, maxAgeMs = SESSION_TTL_MS) { + res.append('Set-Cookie', [ + `${COOKIE_NAME}=${id}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', `Max-Age=${Math.floor(maxAgeMs / 1000)}` + ].join('; ')); +} + +function clearSessionCookie(res) { + res.append('Set-Cookie', `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`); +} + +function clientIp(req) { + const raw = req?.headers?.['x-forwarded-for'] || req?.socket?.remoteAddress || ''; + return String(raw).split(',')[0].trim().replace(/^::ffff:/, '') || null; +} + +/* ---------------------------------------------------------------- 会话 */ + +function createSession(userId, req) { + const now = new Date(); + const id = crypto.randomBytes(32).toString('hex'); + db.prepare(`INSERT INTO sessions (id, user_id, created_at, last_seen_at, expires_at, user_agent, ip) + VALUES (?, ?, ?, ?, ?, ?, ?)`).run( + id, userId, now.toISOString(), now.toISOString(), + new Date(now.getTime() + SESSION_TTL_MS).toISOString(), + String(req?.headers?.['user-agent'] || '').slice(0, 200), clientIp(req) + ); + /* 顺手清掉这把用户的过期会话,避免 sessions 表无限增长 */ + db.prepare('DELETE FROM sessions WHERE user_id = ? AND expires_at < ?').run(userId, now.toISOString()); + return id; +} + +function destroySession(sessionId) { + if (sessionId) db.prepare('DELETE FROM sessions WHERE id = ?').run(sessionId); +} + +/** 从请求里解出当前用户(含 session id),未登录返回 null */ +function sessionUser(req) { + const sessionId = parseCookies(req?.headers?.cookie)[COOKIE_NAME]; + if (!sessionId) return null; + const row = db.prepare(` + SELECT u.*, s.id AS sid, s.expires_at AS session_expires_at + FROM sessions s JOIN users u ON u.id = s.user_id + WHERE s.id = ? + `).get(sessionId); + if (!row) return null; + if (new Date(row.session_expires_at).getTime() < Date.now()) { + destroySession(sessionId); + return null; + } + if (!row.is_active) return null; + return row; +} + +function touchSession(sessionId) { + /* 每小时落一次 last_seen_at 就够,避免每个请求都写库 */ + try { + db.prepare('UPDATE sessions SET last_seen_at = ? WHERE id = ? AND last_seen_at < ?') + .run(new Date().toISOString(), sessionId, new Date(Date.now() - 3600 * 1000).toISOString()); + } catch { /* 非关键路径 */ } +} + +/* ---------------------------------------------------------------- 权限 */ + +function permissionsOfRole(role) { + if (!role) return []; + if (role === 'admin') return PERMISSION_KEYS.slice(); + return db.prepare(` + SELECT p.permission_key AS key + FROM role_permission_groups r + JOIN permission_group_permissions p ON p.group_id = r.group_id + WHERE r.role = ? + `).all(role).map(row => row.key); +} + +function permissionGroupOfRole(role) { + if (!role) return null; + return db.prepare(` + SELECT g.id, g.name, g.description + FROM role_permission_groups r JOIN permission_groups g ON g.id = r.group_id + WHERE r.role = ? + `).get(role) || null; +} + +/** 输出给前端的用户对象:绝不带 password_hash */ +function publicUser(row) { + if (!row) return null; + const org = row.org_id + ? db.prepare('SELECT id, name, code FROM organizations WHERE id = ?').get(row.org_id) + : null; + return { + id: row.id, + username: row.username, + display_name: row.display_name || row.username, + email: row.email || '', + role: row.role || '', + position: row.position || '', + phone: row.phone || '', + address: row.address || '', + avatar: row.avatar || '', + is_active: !!row.is_active, + created_at: row.created_at, + last_login_at: row.last_login_at, + org: org || null, + permission_group: permissionGroupOfRole(row.role), + permissions: permissionsOfRole(row.role) + }; +} + +/* ---------------------------------------------------------------- 中间件 */ + +function attachUser(req, res, next) { + const user = sessionUser(req); + req.user = user; + if (user) touchSession(user.sid); + next(); +} + +function requireAuth(req, res, next) { + if (!req.user) return res.status(401).json({ error: '请先登录' }); + next(); +} + +function requirePermission(key) { + return (req, res, next) => { + if (!req.user) return res.status(401).json({ error: '请先登录' }); + const granted = req.user.role === 'admin' || permissionsOfRole(req.user.role).includes(key); + if (!granted) return res.status(403).json({ error: '当前账号没有该操作权限' }); + next(); + }; +} + +/* ---------------------------------------------------------------- 审计 */ + +function audit({ username, action, detail, req, ok = 1 }) { + try { + db.prepare(`INSERT INTO audit_log (at, username, action, detail, ip, user_agent, ok) + VALUES (?, ?, ?, ?, ?, ?, ?)`).run( + new Date().toISOString(), username || null, action, detail || null, + clientIp(req), String(req?.headers?.['user-agent'] || '').slice(0, 200), ok ? 1 : 0 + ); + } catch { /* 审计是旁路,失败不能影响业务 */ } +} + +module.exports = { + COOKIE_NAME, + SESSION_TTL_MS, + MAX_FAILED, + LOCK_MS, + hashPassword, + verifyPassword, + parseCookies, + setSessionCookie, + clearSessionCookie, + clientIp, + createSession, + destroySession, + sessionUser, + permissionsOfRole, + permissionGroupOfRole, + publicUser, + attachUser, + requireAuth, + requirePermission, + audit +}; diff --git a/backend/lib/branding.js b/backend/lib/branding.js new file mode 100644 index 0000000..44de1e4 --- /dev/null +++ b/backend/lib/branding.js @@ -0,0 +1,71 @@ +/** + * 系统标识与组织名称(单一事实来源)。 + * + * 口径(Wayne 2026-09-15 定,别再改回去): + * - **系统名是作者固定署名,不可修改**:登录页、顶栏、浏览器标题一律显示 + * SYSTEM.name,且**不跟随组织名** —— 这套系统会被别人拿去部署, + * 把牌子固定在作者名下是刻意保留的来源标识(引流)。 + * - **组织名(公司名)可以改**:它只是业务字段,决定「同组织」的资源边界、 + * 以及权限组/用户下拉里的显示;改组织名**不会**影响界面上的系统名。 + * - 之前那套 `settings['brand.name'] / brand.subtitle` 覆盖已作废, + * 历史遗留的这两个键即使还在库里也不再被读取。 + */ + +const db = require('../db/db'); + +/** 系统标识:作者固定署名,界面一律用它;不读 settings,不跟随组织名 */ +const SYSTEM = { + name: 'happy出口通', + subtitle: 'Ai Export System' +}; + +/** 第一个组织视为「主组织」:开箱数据只有一个,组织名以它为准 */ +function primaryOrg() { + try { + return db.prepare('SELECT id, name, code, description FROM organizations ORDER BY id LIMIT 1').get() || null; + } catch { + return null; + } +} + +/** + * 给前端用的标识信息。 + * 故意做得轻:登录页、顶栏、浏览器标题都会调它。 + * name / subtitle 恒为固定值;org_* 只是顺带给设置页展示「当前公司名」。 + */ +function getBranding() { + const org = primaryOrg(); + return { + /* 界面牌子:固定 */ + name: SYSTEM.name, + subtitle: SYSTEM.subtitle, + /* 前端据此把「系统标识」渲染成只读、不提供输入框 */ + system_locked: true, + /* 公司/组织名单独给出:设置页要能看见它当前是什么(这个是可改的) */ + org_name: org?.name || '', + org_id: org?.id ?? null + }; +} + +/** 改组织名(顺带把 code / 描述也支持了,组织与品牌设置页要用) */ +function updateOrg(id, { name, code, description } = {}) { + const row = db.prepare('SELECT * FROM organizations WHERE id = ?').get(Number(id)); + if (!row) throw Object.assign(new Error('组织不存在'), { statusCode: 400 }); + + const nextName = name === undefined ? row.name : String(name || '').trim(); + if (!nextName) throw Object.assign(new Error('组织名称不能为空'), { statusCode: 400 }); + if (nextName.length > 40) throw Object.assign(new Error('组织名称不能超过 40 个字'), { statusCode: 400 }); + + const clash = db.prepare('SELECT id FROM organizations WHERE name = ? AND id <> ?').get(nextName, row.id); + if (clash) throw Object.assign(new Error('已存在同名组织'), { statusCode: 400 }); + + db.prepare('UPDATE organizations SET name = ?, code = ?, description = ? WHERE id = ?').run( + nextName, + code === undefined ? row.code : (String(code || '').trim() || null), + description === undefined ? row.description : (String(description || '').trim() || null), + row.id + ); + return db.prepare('SELECT id, name, code, description FROM organizations WHERE id = ?').get(row.id); +} + +module.exports = { SYSTEM, getBranding, updateOrg, primaryOrg }; diff --git a/backend/lib/contractFiles.js b/backend/lib/contractFiles.js new file mode 100644 index 0000000..dd0d74d --- /dev/null +++ b/backend/lib/contractFiles.js @@ -0,0 +1,165 @@ +/** + * 合同原件的磁盘留存 + 到期清理。 + * + * 为什么放磁盘而不是继续用 `contract_files` 的 BLOB: + * 留原件的唯一目的是「复核那几天能回去核对一眼」,7 天后就要删掉省 VPS 空间。 + * 而 SQLite 删掉 BLOB 后空间并不会还给系统(要 VACUUM 整库重写才还), + * 与「省空间」的目标正好相反。磁盘文件删掉就是删掉。 + * + * 目录结构:/contracts//<清洗过的原文件名> + * —— 用 job_no 分目录,清理时按目录整删即可,不需要额外记路径(也就不需要加字段)。 + * + * ⚠️ 安全:目录名与文件名都来自用户,必须防越权路径。 + * job_no 由服务端生成(CIMP-数字-数字),文件名统一过 sanitizeFileName, + * 拼好之后再校验 resolved 路径确实落在 contracts 根目录之内。 + */ + +const fs = require('fs'); +const path = require('path'); + +const RETENTION_DAYS = Number(process.env.CONTRACT_RETENTION_DAYS || 7); +const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '../data'); +const ROOT = path.join(DATA_DIR, 'contracts'); + +/** 清洗文件名:去掉路径分隔与控制字符,压掉长度,保留中文 */ +function sanitizeFileName(name) { + const base = path.basename(String(name || '')).replace(/[\u0000-\u001f\u007f]/g, '').trim(); + const safe = base.replace(/[\\/:*?"<>|]/g, '_').replace(/^\.+/, ''); + const trimmed = safe.slice(0, 120) || 'contract'; + return trimmed; +} + +/** 防越权:任何对外暴露的路径都要过这一关 */ +function resolveInside(jobNo, fileName) { + const dir = path.join(ROOT, String(jobNo || '').replace(/[^\w-]/g, '')); + const target = path.join(dir, sanitizeFileName(fileName)); + const rootResolved = path.resolve(ROOT); + const targetResolved = path.resolve(target); + if (targetResolved !== rootResolved && !targetResolved.startsWith(rootResolved + path.sep)) return null; + return targetResolved; +} + +/** + * 保存一份原件。失败不抛 —— 原件只是辅助材料,解析结果才是主产物, + * 不能因为磁盘满了就把整次导入判失败。 + */ +function saveOriginal(jobNo, file) { + try { + if (!jobNo || !file?.buffer?.length) return null; + const dir = path.join(ROOT, String(jobNo).replace(/[^\w-]/g, '')); + fs.mkdirSync(dir, { recursive: true }); + + // 同名文件加序号后缀,避免后来者覆盖前一个 + const wanted = sanitizeFileName(file.originalname); + let target = path.join(dir, wanted); + let seq = 1; + const ext = path.extname(wanted); + const stem = path.basename(wanted, ext); + while (fs.existsSync(target)) { + seq += 1; + target = path.join(dir, `${stem}-${seq}${ext}`); + } + + fs.writeFileSync(target, file.buffer); + return { name: path.basename(target), size: file.buffer.length }; + } catch { + return null; + } +} + +/** 列出某个任务还留着的原件 */ +function listOriginals(jobNo) { + try { + const dir = path.join(ROOT, String(jobNo).replace(/[^\w-]/g, '')); + if (!fs.existsSync(dir)) return []; + return fs.readdirSync(dir).map(name => { + const stat = fs.statSync(path.join(dir, name)); + return { name, size: stat.size, stored_at: stat.mtime.toISOString() }; + }); + } catch { + return []; + } +} + +/** 取某一份原件(下载用)。不存在返回 null —— 界面据此显示「已过期清理」。 */ +function readOriginal(jobNo, fileName) { + const target = resolveInside(jobNo, fileName); + if (!target) return null; + try { + if (!fs.existsSync(target) || !fs.statSync(target).isFile()) return null; + return { path: target, name: path.basename(target), buffer: fs.readFileSync(target) }; + } catch { + return null; + } +} + +/** + * 清理过期原件。 + * + * 到期判定用**任务创建时间**(库里查得到的权威时间),不用目录 mtime —— + * mtime 会被「同 job 再传一次文件」刷新,导致该清的清不掉。 + * 库里查不到对应任务的孤儿目录,按目录 mtime 兜底删。 + */ +function purgeExpired(db, retentionDays = RETENTION_DAYS) { + const removed = []; + const cutoffMs = Date.now() - retentionDays * 24 * 60 * 60 * 1000; + let dirs = []; + try { + if (!fs.existsSync(ROOT)) return { removed, scanned: 0 }; + dirs = fs.readdirSync(ROOT).filter(name => { + try { return fs.statSync(path.join(ROOT, name)).isDirectory(); } catch { return false; } + }); + } catch { + return { removed, scanned: 0 }; + } + + for (const jobNo of dirs) { + const dir = path.join(ROOT, jobNo); + let expired = false; + try { + const job = db.prepare('SELECT created_at FROM contract_import_jobs WHERE job_no = ?').get(jobNo); + const stamp = job?.created_at ? Date.parse(job.created_at) : fs.statSync(dir).mtimeMs; + expired = Number.isFinite(stamp) && stamp < cutoffMs; + } catch { + expired = false; + } + if (!expired) continue; + try { + fs.rmSync(dir, { recursive: true, force: true }); + removed.push(jobNo); + } catch { + /* 删不掉(占着句柄等)下一轮再试,不影响服务 */ + } + } + return { removed, scanned: dirs.length }; +} + +/** + * 启动时清一次 + 之后每 6 小时一次。 + * 用 unref() 让定时器不阻止进程退出(否则 nodemon 重启会挂着)。 + */ +function startPurgeSchedule(db, logger = console) { + const run = () => { + try { + const { removed } = purgeExpired(db); + if (removed.length) logger.log(`[contracts] 已清理 ${removed.length} 个到期的合同原件(保留 ${RETENTION_DAYS} 天)`); + } catch (error) { + logger.error?.('[contracts] 清理合同原件失败:', error.message); + } + }; + run(); + const timer = setInterval(run, 6 * 60 * 60 * 1000); + timer.unref?.(); + return timer; +} + +module.exports = { + RETENTION_DAYS, + ROOT, + sanitizeFileName, + saveOriginal, + listOriginals, + readOriginal, + purgeExpired, + startPurgeSchedule +}; diff --git a/backend/lib/contractParser.js b/backend/lib/contractParser.js new file mode 100644 index 0000000..a9e30f2 --- /dev/null +++ b/backend/lib/contractParser.js @@ -0,0 +1,609 @@ +/** + * 通用表格解析引擎:把「供应商合同 / 报价单」这类文件认成结构化的行。 + * + * 设计目标(三条,缺一不可): + * + * 1. **按字符认列,不按行列位置。** + * 规则只描述「这一列的标题大概长什么样」(关键词 / 正则),不写死「第 3 列是价格」。 + * 同一份表加一列、调一下顺序,识别结果不变。 + * + * 2. **只采纳「标题对得上、且值确实是数值」的列。** + * 数值类字段(采购价 / 数量 / 起订量)在认列之后还要过一道**列级体检**: + * 这一列的非空值里能解析成数字的比例达标,才允许采纳。 + * 这一条是「通用」的关键 —— 它挡住的不是错别字,而是「品名里带个『价』字」 + * 这类只看标题一定会踩的坑。 + * + * 3. **通用、不绑定行业。** + * 默认真名词典按「语义」组织(中/英 + 常见缩写),词典里**不允许出现任何行业专有词** + * (五金、灯具、化工……一概不写)。换一个行业只是换个文件,不是改代码。 + * 词典本身也写成**数据**:可在 `settings['contract.rules']` 或 + * `DATA_DIR/contract-rules.json` 里增删关键词、排除词、阈值,覆盖默认值。 + * + * 表头不在第一行也能认:标题行 / 公司抬头 / 日期这些常常占掉前几行, + * 所以会**在前若干行里挑「规则命中最多」的那一行当表头**,而不是无脑取第 1 行。 + */ + +const path = require('path'); +const fs = require('fs'); + +const db = require('../db/db'); + +let XLSX = null; +try { + // 读 xlsx/xls 用;放在 try 里,没装也不至于整个服务起不来 + XLSX = require('xlsx'); +} catch { + XLSX = null; +} + +const SETTINGS_KEY = 'contract.rules'; + +const DEFAULT_OPTIONS = { + /** 表头最多往下找几行 */ + header_search_rows: 12, + /** 认定「这行是表头」至少要有几个字段命中 */ + header_min_hits: 2, + /** 认列的最低分(低于此分视为没认出来) */ + min_score: 45, + /** 数值列体检:能解析成数字的比例下限 */ + min_numeric_ratio: 0.6, + /** 数值列体检最多抽几行 */ + numeric_sample: 40 +}; + +/* ------------------------------------------------------------------ 词典 */ + +/** + * 默认规则。 + * + * 约定: + * type text / number —— number 的列要过数值体检 + * keys 命中即得分的关键词(中英混排,不分大小写) + * exclude 硬排除:命中就直接判定「这一列不是这个字段」 + * regex 额外的正则特征(写字符串,用 /.../i 形式) + * + * ⚠️ 这里的词必须是**通用词**。加词前先问:换个行业还成立吗? + */ +const DEFAULT_FIELDS = { + sku: { + label: '货号', + type: 'text', + keys: [ + 'sku', 'item code', 'item no', 'item number', 'product code', 'product no', 'product number', + 'part no', 'part number', 'model no', 'model number', 'article no', 'article number', + 'style no', 'style number', 'catalog no', 'cat no', 'ref no', 'reference', + 'code', 'model', 'article', 'product id', 'item id', + '货号', '产品编号', '产品编码', '产品货号', '商品编号', '物料编号', '物料编码', '物料号', + '型号', '型号规格', '编号', '款号', '图号', '零件号', '存货编码', '存货编号' + ], + exclude: ['数量', '价', 'price', 'cost', 'amount', '名称', 'name', 'qty', 'quantity'] + }, + product_name: { + label: '品名', + type: 'text', + keys: [ + 'product name', 'item name', 'goods name', 'commodity', 'product description', + 'item description', 'description', 'product', 'item', 'title', 'goods', + '品名', '产品名称', '商品名称', '货物名称', '产品描述', '商品描述', + '品名规格', '名称', '描述', '品名及规格', '产品' + ], + exclude: ['货号', '编号', '编码', 'code', 'no.', '数量', '价', 'price', 'amount', 'qty', '规格型号'] + }, + spec: { + label: '规格', + type: 'text', + keys: [ + 'spec', 'specification', 'specifications', 'size', 'dimension', 'dimensions', + 'material', 'model spec', 'type', + '规格', '规格型号', '尺寸', '材质', '参数', '规格参数', '规格描述' + ], + exclude: ['货号', '编号', '价', 'price', '数量', 'qty', 'amount', '名称'] + }, + purchase_price: { + label: '采购价', + type: 'number', + keys: [ + 'purchase price', 'buying price', 'cost price', 'factory price', 'unit price', + 'net price', 'exw price', 'fob price', 'ex works', 'exw', 'fob', 'cost', 'price', + '采购价', '采购单价', '采购价格', '供货价', '供货单价', '出厂价', '出厂单价', + '成本价', '成本单价', '未税价', '不含税价', '含税价', '工厂价', '底价', '单价', '价格', '报价' + ], + exclude: [ + '总价', '合计', '金额', '总额', '小计', 'amount', 'total', 'sum', + '售价', '零售', '建议零售', 'retail', 'msrp', 'selling', 'sell price', + '折扣', 'discount', '毛利', '利润', 'profit', 'margin', '汇率', 'rate', '税点', '运费', 'freight' + ] + }, + currency: { + label: '币种', + type: 'text', + keys: ['currency', 'cur', 'unit', '币种', '货币', '币别', '结算币种', '单价币种', '计价币种'], + exclude: ['数量', 'qty', '价', 'price', '货号', '编号'] + }, + quantity: { + label: '数量', + type: 'number', + keys: [ + 'quantity', 'qty', 'order qty', 'order quantity', 'purchase qty', 'purchase quantity', + 'ordered qty', 'amount of order', 'pcs', 'sets', 'qty(pcs)', + '数量', '订购量', '订购数量', '采购数量', '采购量', '订货量', '订货数量', '数量(pcs)', '件数', '个数' + ], + exclude: ['价', 'price', 'cost', 'amount', '金额', '总', '合计', 'moq', '起订', 'min', '最少'] + }, + moq: { + label: '起订量', + type: 'number', + keys: [ + 'moq', 'minimum order quantity', 'min order qty', 'min order', 'minimum qty', 'min qty', + '起订量', '最小起订量', '起订数量', '最少订量', '最小订量', '最小起订' + ], + exclude: ['价', 'price', 'cost', 'amount', '金额'] + } +}; + +/* -------------------------------------------------------------- 配置读取 */ + +function readJsonFile(file) { + try { + if (!fs.existsSync(file)) return null; + return JSON.parse(fs.readFileSync(file, 'utf8')); + } catch { + return null; + } +} + +/** 用户覆盖文件放哪:与数据库同目录,便于随库一起备份 */ +function overrideFile() { + const dir = process.env.DATA_DIR || path.join(__dirname, '../data'); + return path.join(dir, 'contract-rules.json'); +} + +function readOverride() { + /* 优先级:DATA_DIR/contract-rules.json(真正意义的「配置文件」) > settings 表。 + 前者适合开源用户直接改文件、连界面都不进;后者留给以后的界面化编辑。 */ + const fromFile = readJsonFile(overrideFile()); + if (fromFile && typeof fromFile === 'object') return { source: 'file', data: fromFile }; + try { + const raw = db.prepare('SELECT value FROM settings WHERE key = ?').get(SETTINGS_KEY)?.value; + if (raw) return { source: 'settings', data: JSON.parse(raw) }; + } catch { + /* 表还没建或值是脏的,当没有覆盖处理 */ + } + return { source: null, data: {} }; +} + +/** + * 合并默认规则与用户覆盖。 + * 覆盖写法(只写要改的部分即可): + * { + * "fields": { "purchase_price": { "add": ["供货价"], "exclude": ["吊牌价"] } }, + * "options": { "min_numeric_ratio": 0.5 }, + * "disable": ["spec"] + * } + */ +function loadRules() { + const { source, data } = readOverride(); + const options = { ...DEFAULT_OPTIONS, ...(data.options || {}) }; + const disabled = new Set(Array.isArray(data.disable) ? data.disable : []); + const fields = {}; + + for (const [key, def] of Object.entries(DEFAULT_FIELDS)) { + if (disabled.has(key)) continue; + const patch = data.fields?.[key] || {}; + const keys = [...def.keys, ...(Array.isArray(patch.add) ? patch.add : [])] + .filter(item => !(Array.isArray(patch.remove) && patch.remove.includes(item))); + const exclude = [...def.exclude, ...(Array.isArray(patch.exclude) ? patch.exclude : [])]; + fields[key] = { + ...def, + keys, + exclude, + // type 允许被覆盖(有人想把「规格」当数值列 тоже可以) + type: patch.type || def.type, + regex: Array.isArray(patch.regex) ? patch.regex : [] + }; + } + return { fields, options, override_source: source }; +} + +/* ------------------------------------------------------------ 文本与数字 */ + +/** 全角 → 半角、统一括号、去零宽字符。中文表格里这三样最常见。 */ +function normalizeText(value) { + return String(value ?? '') + .replace(/[\uFF01-\uFF5E]/g, ch => String.fromCharCode(ch.charCodeAt(0) - 0xfee0)) + .replace(/\u3000/g, ' ') + .replace(/[\u200B-\u200D\uFEFF]/g, '') + .trim(); +} + +/** 表头归一:小写、去空白、去尾部标点,让「采购价 (元)」和「采购价」能对上 */ +function normalizeHeader(value) { + return normalizeText(value) + .toLowerCase() + .replace(/\s+/g, '') + .replace(/[::、,,.。**·\-—]+$/g, '') + .replace(/[((].*?[))]$/g, ''); // 去掉尾部的单位括号 +} + +const hasCJK = text => /[\u4E00-\u9FFF]/.test(text); + +/** + * 把单元格里的值转成数字。 + * 处理:全角数字、千分位(含欧洲写法 1.234,56)、币种符号、单位后缀、 + * 括号负数(会计写法)、前后空白与不可见字符。 + * 返回 null 表示「这不是一个数字」——调用方据此做列级体检。 + */ +function toNumber(value) { + if (value === null || value === undefined || value === '') return null; + if (typeof value === 'number') return Number.isFinite(value) ? value : null; + + let text = normalizeText(value).replace(/\s/g, ''); + if (!text) return null; + + // 括号负数:(12.5) → -12.5 + let negative = false; + if (/^\(.*\)$/.test(text)) { + negative = true; + text = text.slice(1, -1); + } + + // 欧洲写法:1.234,56 —— 逗号是小数点、点是千分位。必须先判,否则会被当千分位剥掉 + if (/^\d{1,3}(\.\d{3})+(,\d+)?$/.test(text)) { + text = text.replace(/\./g, '').replace(',', '.'); + } else { + text = text.replace(/,/g, ''); + } + + // 剥掉一切非数字字符(币种符号、单位、文字都在这步掉) + text = text.replace(/[^0-9.\-+]/g, ''); + if (!text) return null; + + /* 符号只允许出现在最前面。 + ⚠️ 不能「把 - 全省掉」—— 那样「12-15」这种区间会被拼成 1215, + 比留空更糟:它会当成真价继续往产品库里走。区间一律判为非数字。 */ + let sign = 1; + if (/^[-+]/.test(text)) { + if (text[0] === '-') sign = -1; + text = text.slice(1); + } + if (/[-+]/.test(text)) return null; + if (!/^\d*\.?\d+$/.test(text)) return null; + + const num = Number(text) * sign * (negative ? -1 : 1); + return Number.isFinite(num) ? num : null; +} + +/** 从整行的内容里猜币种 —— 没有币种列时用 */ +const CURRENCY_HINTS = [ + [/¥|¥|人民币|rmb|cny|元/i, 'CNY'], + [/\$|usd|美元|美金/i, 'USD'], + [/€|eur|欧元/i, 'EUR'], + [/£|gbp|英镑/i, 'GBP'], + [/jpy|日元|円/i, 'JPY'], + [/krw|韩元|₩/i, 'KRW'], + [/aud|澳元/i, 'AUD'], + [/cad|加元/i, 'CAD'], + [/hkd|港币|港元/i, 'HKD'], + [/inr|卢比/i, 'INR'], + [/vnd|越南盾|đ/i, 'VND'], + [/thb|泰铢|฿/i, 'THB'] +]; + +function guessCurrency(...texts) { + const joined = texts.map(item => String(item ?? '')).join(' '); + if (!joined.trim()) return null; + for (const [pattern, code] of CURRENCY_HINTS) { + if (pattern.test(joined)) return code; + } + // 三字母代码(USD / CNY…)单独兜一次,避免被上面的贪婪匹配漏掉 + const code = joined.match(/\b([A-Z]{3})\b/); + return code ? code[1] : null; +} + +/* ---------------------------------------------------------------- 列匹配 */ + +/** 单个「表头 × 字段」打分。返回 0 = 不匹配;exclude 命中直接 0。 */ +function scoreHeader(header, field) { + const head = normalizeHeader(header); + if (!head) return 0; + + for (const bad of field.exclude) { + const token = normalizeHeader(bad); + if (token && head.includes(token)) return 0; + } + + let best = 0; + for (const rawKey of field.keys) { + const key = normalizeHeader(rawKey); + if (!key) continue; + + if (head === key) return 100; + + if (hasCJK(key)) { + // 中文:包含即可,但关键词越长越可信(「采购单价」远比「价」可信) + if (head.includes(key)) best = Math.max(best, 50 + key.length * 8); + } else { + // 拉丁:短词(no / moq / ref…)必须落在词边界上,否则 notes 会被当成 no + const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const boundary = new RegExp(`(^|[^a-z0-9])${escaped}([^a-z0-9]|$)`); + if (boundary.test(head)) best = Math.max(best, 55 + Math.min(key.length, 12) * 3); + } + } + + for (const pattern of field.regex) { + try { + const [source, flags] = pattern.split(/\/([a-z]*)$/); + if (new RegExp(source.replace(/^\//, ''), flags || 'i').test(header)) best = Math.max(best, 85); + } catch { + /* 用户写的正则不合法就当没写,不能让一份配置文件把整条解析链路打挂 */ + } + } + return best; +} + +/** + * 认列:把「表头行」映射成「字段 → 列号」。 + * + * 两步走: + * ① 每个数值字段先过**列级体检** —— 候选列的数字占比不达标直接淘汰(拦「品名带价字」这类误判) + * ② 所有候选按分排序,贪心分配:字段没被占、列也没被占才落座 + * (贪心而不是「每个字段各取最高分」,是为了避免两列抢同一个字段时出现重复占用) + */ +function matchColumns(headers, dataRows, rules) { + const { fields, options } = rules; + const candidates = []; + const rejected = []; + + for (const [fieldKey, field] of Object.entries(fields)) { + for (let col = 0; col < headers.length; col += 1) { + const score = scoreHeader(headers[col], field); + if (score < options.min_score) continue; + + if (field.type === 'number') { + const sample = dataRows + .map(row => row[col]) + .filter(value => String(value ?? '').trim() !== '') + .slice(0, options.numeric_sample); + if (!sample.length) { + rejected.push({ field: fieldKey, col, header: headers[col], reason: '整列为空' }); + continue; + } + const ok = sample.filter(value => toNumber(value) !== null).length; + const ratio = ok / sample.length; + if (ratio < options.min_numeric_ratio) { + rejected.push({ + field: fieldKey, col, header: headers[col], + reason: `数值占比 ${Math.round(ratio * 100)}% 低于 ${Math.round(options.min_numeric_ratio * 100)}%` + }); + continue; + } + } + candidates.push({ field: fieldKey, col, header: String(headers[col] ?? '').trim(), score }); + } + } + + candidates.sort((a, b) => b.score - a.score || a.col - b.col); + const usedField = new Set(); + const usedCol = new Set(); + const map = {}; + for (const item of candidates) { + if (usedField.has(item.field) || usedCol.has(item.col)) continue; + usedField.add(item.field); + usedCol.add(item.col); + map[item.field] = item; + } + return { map, rejected }; +} + +/** + * 挑表头行。 + * 真实文件里表头常常不在第 1 行(上面还有公司抬头、报价日期、说明……), + * 所以在前若干行里找「字段命中最多」的那一行,而不是无脑取第 1 行。 + */ +function detectHeaderRow(matrix, rules) { + const limit = Math.min(matrix.length, rules.options.header_search_rows); + let best = { index: 0, hits: 0, matched: null }; + for (let i = 0; i < limit; i += 1) { + const row = matrix[i] || []; + const { map } = matchColumns(row, matrix.slice(i + 1), rules); + const hits = Object.keys(map).length; + // 命中更多者胜;打平取靠前的(抬头行通常在上面) + if (hits > best.hits) best = { index: i, hits, matched: map }; + } + return best; +} + +/* ---------------------------------------------------------------- 读文件 */ + +/** CSV / TSV / TXT:按首行含不含 Tab 决定分隔符(沿用原有口径) */ +function readDelimited(buffer, filename) { + const text = buffer.toString('utf8').replace(/^\uFEFF/, ''); + const lines = text.split(/\r?\n/); + const delimiter = /\.tsv$/i.test(filename) || (lines[0] || '').includes('\t') ? '\t' + : (lines[0] || '').includes(';') && !(lines[0] || '').includes(',') ? ';' + : ','; + return lines.map(line => splitDelimited(line, delimiter)); +} + +/** 按分隔符切一行,但引号里的分隔符不算("A,B" 是一格) */ +function splitDelimited(line, delimiter) { + const cells = []; + let current = ''; + let quoted = false; + for (let i = 0; i < line.length; i += 1) { + const ch = line[i]; + if (ch === '"') { + if (quoted && line[i + 1] === '"') { current += '"'; i += 1; } else quoted = !quoted; + } else if (ch === delimiter && !quoted) { + cells.push(current); + current = ''; + } else { + current += ch; + } + } + cells.push(current); + return cells.map(cell => cell.trim()); +} + +/** xlsx / xls:交给 SheetJS;多 sheet 时取「非空单元格最多」的那个(通常就是明细表) */ +function readWorkbook(buffer) { + if (!XLSX) throw new Error('未安装 xlsx 解析库,无法读取 Excel 文件'); + const book = XLSX.read(buffer, { type: 'buffer' }); + let best = { matrix: [], cells: -1, sheet: null }; + for (const name of book.SheetNames || []) { + const sheet = book.Sheets[name]; + if (!sheet) continue; + const matrix = XLSX.utils.sheet_to_json(sheet, { header: 1, raw: false, defval: '', blankrows: true }); + const cells = matrix.reduce((sum, row) => sum + row.filter(cell => String(cell ?? '').trim() !== '').length, 0); + if (cells > best.cells) best = { matrix, cells, sheet: name }; + } + return best; +} + +function parseMatrixToRows(matrix, headerIndex, columnMap, fallbackCurrency = null) { + const fields = Object.keys(columnMap); + const rows = []; + for (let i = headerIndex + 1; i < matrix.length; i += 1) { + const raw = matrix[i] || []; + const pick = field => { + const hit = columnMap[field]; + if (!hit) return null; + const value = normalizeText(raw[hit.col]); + return value === '' ? null : value; + }; + const row = { + // row_no 用「文件里的真实行号」(1 起),方便人回去对着原文件找 + row_no: i + 1, + raw_text: raw.map(cell => String(cell ?? '').trim()).filter(Boolean).join(' | ').slice(0, 500), + sku: pick('sku'), + product_name: pick('product_name'), + spec: pick('spec'), + currency: pick('currency'), + quantity: toNumber(pick('quantity')), + moq: toNumber(pick('moq')), + purchase_price: toNumber(pick('purchase_price')) + }; + /* 币种三级兜底:本行的币种列(上面 pick 过了) → 本行里的符号(¥ / USD / 元) + → 整份文件的线索(表头常写「单价(元)」「Unit Price(USD)」)。 + 只按单行猜会出现「同一列有的行有币种、有的行没有」这种不一致。 */ + if (!row.currency) row.currency = guessCurrency(raw.join(' ')) || fallbackCurrency; + // 整行该抓的全空 —— 大概率是空行或小计行,丢掉 + const meaningful = [row.sku, row.product_name, row.spec, row.purchase_price, row.quantity, row.moq] + .some(value => value !== null && value !== undefined && value !== ''); + if (meaningful) rows.push(row); + } + return { rows, fields }; +} + +/** + * 解析一个上传文件 → { rows, headers, header_row, column_map, rejected, notes } + * 这是对外的唯一入口。 + */ +function parseUpload(file, rules = loadRules()) { + const filename = String(file.originalname || ''); + const isExcel = /\.(xlsx|xlsm|xls)$/i.test(filename); + let matrix; + let sheetName = null; + + if (isExcel) { + const book = readWorkbook(file.buffer); + matrix = book.matrix; + sheetName = book.sheet; + } else { + matrix = readDelimited(file.buffer, filename); + } + + // 去掉整行全空的(xlsx 里尾部常有大量空行) + matrix = matrix.filter(row => row.some(cell => String(cell ?? '').trim() !== '')); + if (!matrix.length) { + return { rows: [], headers: [], header_row: null, column_map: {}, rejected: [], notes: '文件里没有读到任何内容' }; + } + + const header = detectHeaderRow(matrix, rules); + const headers = matrix[header.index] || []; + const { map, rejected } = matchColumns(headers, matrix.slice(header.index + 1), rules); + /* 文件级币种线索:表头 + 表头以上的抬头行(「报价单(美元)」「单价(元)」都在这) + —— 没有币种列时靠它,保证整列币种一致 */ + const titleRows = matrix.slice(0, header.index).flat(); + const fileCurrency = guessCurrency(headers.join(' '), titleRows.join(' ')); + const { rows } = parseMatrixToRows(matrix, header.index, map, fileCurrency); + + const notes = rows.length + ? `表头在第 ${header.index + 1} 行,认出 ${Object.keys(map).length} 个字段${sheetName ? `(工作表 ${sheetName})` : ''}` + : '表头行没有认出任何字段,建议人工补录或用 AI 解析'; + + return { + rows, + headers: headers.map(cell => String(cell ?? '').trim()), + header_row: header.index + 1, + column_map: map, + rejected, + currency: fileCurrency, + notes + }; +} + +/** 读成二维数组(xlsx / csv / tsv / txt 统一入口),AI 解析与规则解析共用 */ +function readMatrix(buffer, filename) { + const isExcel = /\.(xlsx|xlsm|xls)$/i.test(String(filename || '')); + const matrix = isExcel ? readWorkbook(buffer).matrix : readDelimited(buffer, filename); + return matrix.filter(row => row.some(cell => String(cell ?? '').trim() !== '')); +} + +/** 二维数组 → CSV 文本,喂给模型用 */ +function matrixToText(matrix, { maxRows = 200, maxChars = 12000 } = {}) { + const lines = []; + let chars = 0; + for (const row of matrix) { + const line = row.map(cell => { + const text = String(cell ?? '').replace(/\s+/g, ' ').trim(); + return /[",]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; + }).join(',').replace(/(?:,)+$/, ''); + if (!line.trim()) continue; + if (lines.length >= maxRows || chars + line.length > maxChars) { + lines.push('…(余下内容已截断)'); + break; + } + lines.push(line); + chars += line.length; + } + return lines.join('\n'); +} + +/** + * 把上传的原件转成「给模型看的文本」。 + * ⚠️ 只做格式转换,不做任何猜测 —— 猜是模型的事,转换是我们的事。 + */ +function describeForAi(buffer, filename, options) { + const matrix = readMatrix(buffer, filename); + return { text: matrixToText(matrix, options), row_count: matrix.length }; +} + +/** 给界面用:把认列结果翻译成人能读的一段话 */ +function describeMatch(columnMap, rejected = []) { + const picked = Object.entries(columnMap) + .map(([field, hit]) => `${DEFAULT_FIELDS[field]?.label || field}←「${hit.header}」(${hit.score}分)`) + .join(','); + const miss = rejected.length + ? `;被否掉:${rejected.map(item => `${item.header}(${item.reason})`).join(',')}` + : ''; + return (picked || '没有认出任一字段') + miss; +} + +module.exports = { + DEFAULT_FIELDS, + DEFAULT_OPTIONS, + loadRules, + overrideFile, + parseUpload, + readMatrix, + matrixToText, + describeForAi, + matchColumns, + detectHeaderRow, + describeMatch, + toNumber, + normalizeHeader, + guessCurrency, + scoreHeader +}; diff --git a/backend/lib/customerAccess.js b/backend/lib/customerAccess.js new file mode 100644 index 0000000..76df59d --- /dev/null +++ b/backend/lib/customerAccess.js @@ -0,0 +1,91 @@ +/** + * 客户归属与可见性口径(唯一事实来源)。 + * + * Wayne 2026-09-14 定的规矩: + * ① 客户有「所属」:谁建的归谁,默认只有归属人自己看得见; + * ② 创建人主动丢进公海(pool_status = 'public')后,同组织成员都能看见、都能领取; + * ③ 其余情况只有拿到 `customer.view_all` 的最高级别管理账号能查看。 + * + * 「能看」与「能改」是两档: + * 公海里的客户同组织成员都能看见(不然没法判断值不值得领), + * 但只有归属人或管理账号能改 —— 别人想改得先「领取」。 + * + * ⚠️ 注意 req.user 是 users 表的原始行(attachUser 直接塞的是 sessionUser 的结果), + * 上面**没有** permissions 字段,权限必须用 auth.permissionsOfRole(role) 现推。 + */ +const auth = require('./auth'); + +/** 越过归属看全部客户的权限点 */ +const VIEW_ALL = 'customer.view_all'; + +/** 是否属于「最高级别系统管理账号」 */ +function seesAll(user) { + if (!user || !user.role) return false; + return auth.permissionsOfRole(user.role).includes(VIEW_ALL); +} + +/** + * 列表可见性 → 可直接拼进 WHERE 的条件数组 + 参数。 + * + * scope: + * 'mine' 我的客户(默认)—— owner_id = 我 + * 'all' 全部客户 —— 需 customer.view_all,无权限时退回 'mine' 而不是报错 + * 'pool' 客户公海 —— pool_status = 'public',且限定本组织 + * + * 调用方拼 SQL 时,customers 表必须别名为 **c**。 + */ +function scopeFilter(user, scope = 'mine') { + const conditions = []; + const params = []; + + if (scope === 'pool') { + conditions.push("c.pool_status = 'public'"); + if (user && user.org_id) { + /* 公海是「组织(团队)」的资源,只放本组织的进来。 + 存量没 org_id 的老数据不设门槛,免得刚升级就凭空少一批。 */ + conditions.push('(c.org_id IS NULL OR c.org_id = ?)'); + params.push(user.org_id); + } + return { conditions, params }; + } + + if (scope === 'all' && seesAll(user)) { + return { conditions, params }; + } + + /* 「我的客户」= 归我 + 还没交出去。 + 丢进公海后这条就从我的列表里移走,只留在「客户公海」—— + 否则用户会疑惑「我都丢出去了怎么还在我这儿」。 */ + conditions.push('c.owner_id = ?'); + conditions.push("COALESCE(c.pool_status, 'owned') <> 'public'"); + params.push(user ? user.id : -1); + return { conditions, params }; +} + +/** 公海是「组织(团队)」的资源:两边都记了组织且不一致,就是外人 */ +function inSameOrg(user, customer) { + const mine = user?.org_id; + const theirs = customer?.org_id; + /* 存量数据可能没组织,不设门槛 —— 与 scopeFilter 里 pool 的写法保持一致 */ + if (!mine || !theirs) return true; + return Number(mine) === Number(theirs); +} + +/** 能不能看这条客户(详情页、跟进时间线) */ +function canView(user, customer) { + if (!customer) return false; + if (seesAll(user)) return true; + if (user && Number(customer.owner_id) === Number(user.id)) return true; + /* 公海客户同组织可见;别组织的公海客户连详情都不该看到 */ + if (customer.pool_status === 'public' && inSameOrg(user, customer)) return true; + return false; +} + +/** 能不能改这条客户(基本信息、联系人、跟进记录) */ +function canEdit(user, customer) { + if (!customer) return false; + if (seesAll(user)) return true; + return !!user && Number(customer.owner_id) === Number(user.id); +} + +module.exports = { VIEW_ALL, seesAll, scopeFilter, inSameOrg, canView, canEdit }; diff --git a/backend/lib/fx.js b/backend/lib/fx.js index ae8f729..564856d 100644 --- a/backend/lib/fx.js +++ b/backend/lib/fx.js @@ -23,8 +23,8 @@ const CACHE_FILE = path.join(DATA_DIR, 'fx-cache.json'); /** 内存缓存的保鲜期:1 小时后下次访问会去拉新数据。 */ const TTL_MS = 60 * 60 * 1000; -/** 单个数据源的超时。 */ -const TIMEOUT_MS = 8000; +/** 单个数据源的超时。多个源是并行竞速的,所以这基本等于整体最坏耗时。 */ +const TIMEOUT_MS = 5000; const USER_AGENT = 'happy-export-fx'; /* ------------------------------ 数据源 ------------------------------ */ @@ -149,25 +149,41 @@ let memory = readDiskCache(); /** 同一个进程里并发调用只发一次网络请求。 */ let inflight = null; +/** + * 刷新汇率表。 + * + * 所有数据源**并行竞速**,第一个拿到完整数据的胜出。 + * 早先这里是串行 for 循环,一次网络抖动要等 8s × 2 = 16s 才回退, + * 用户看到的是「刷新失败 + 3 天前的旧数据」——其实另一个源可能早就成功了。 + * 并行之后最坏情况只等一个超时周期。 + */ async function refresh() { - for (const provider of PROVIDERS) { + const attempts = PROVIDERS.map(async provider => { const json = await fetchJson(provider.url); const picked = json && provider.pick(json); - if (!picked) continue; + if (!picked) throw new Error(`${provider.id} 无数据`); const rates = sanitizeRates(picked.rates); - if (Object.keys(rates).length < 10) continue; // 明显不完整,换下一个源 - const payload = { - rates, - updatedAt: picked.updatedAt, - fetchedAt: Date.now(), - source: provider.id, - sourceLabel: provider.label - }; - memory = payload; - writeDiskCache(payload); - return payload; + if (Object.keys(rates).length < 10) throw new Error(`${provider.id} 数据不完整`); + return { provider, rates, updatedAt: picked.updatedAt }; + }); + + let winner; + try { + winner = await Promise.any(attempts); + } catch { + return null; // 所有源都失败,交给 getRates 走缓存 / 离线兜底 } - return null; + + const payload = { + rates: winner.rates, + updatedAt: winner.updatedAt, + fetchedAt: Date.now(), + source: winner.provider.id, + sourceLabel: winner.provider.label + }; + memory = payload; + writeDiskCache(payload); + return payload; } /** diff --git a/backend/lib/permissions.js b/backend/lib/permissions.js new file mode 100644 index 0000000..5b4c2a9 --- /dev/null +++ b/backend/lib/permissions.js @@ -0,0 +1,123 @@ +/** + * 系统权限点清单(单一事实来源)。 + * + * 设计口径(Wayne 2026-09-14 定): + * - 「角色」是职位显示(董事长 / 总经理 / 销售经理 / 销售业务员 …),本身不带权限; + * - 权限全部收敛到「权限组」:一个组勾选一批权限点(即下面的 key); + * - 把角色指派进某个组(role_permission_groups),该角色下的所有用户即获得这组权限。 + * + * 命名约定:`模块.动作`。前端菜单过滤、路由守卫、后端 requirePermission 都用同一批 key。 + */ + +const PERMISSIONS = [ + /* 工作台 */ + { key: 'dashboard.view', label: '查看工作台', group: '工作台' }, + + /* 业务管理 */ + { key: 'inquiry.view', label: '查看询盘管理', group: '业务管理' }, + { key: 'inquiry.edit', label: '编辑询盘管理', group: '业务管理' }, + { key: 'quote.view', label: '查看客户报价单', group: '业务管理' }, + { key: 'quote.edit', label: '编辑客户报价单', group: '业务管理' }, + { key: 'contract.view', label: '查看 PI 合同', group: '业务管理' }, + { key: 'contract.edit', label: '编辑 PI 合同', group: '业务管理' }, + { key: 'purchase.view', label: '查看采购单', group: '业务管理' }, + { key: 'purchase.edit', label: '编辑采购单', group: '业务管理' }, + { key: 'order.view', label: '查看订单管理', group: '业务管理' }, + { key: 'order.edit', label: '编辑订单管理', group: '业务管理' }, + + /* 客户管理 */ + { key: 'customer.view', label: '查看我的客户', group: '客户管理' }, + { key: 'customer.edit', label: '编辑我的客户', group: '客户管理' }, + /* 客户默认「谁建的谁看得见」,丢进公海才对同组织开放。 + 拿到这个点才越过归属看全部客户 —— 按 Wayne 的口径只给最高级别的系统管理组。 */ + { key: 'customer.view_all', label: '查看全部客户(含他人归属)', group: '客户管理' }, + { key: 'prospect.view', label: '新客开发', group: '客户管理' }, + { key: 'pool.view', label: '客户公海', group: '客户管理' }, + { key: 'pool.claim', label: '客户公海领取', group: '客户管理' }, + + /* 采购管理 */ + { key: 'product.view', label: '查看产品管理', group: '采购管理' }, + { key: 'product.edit', label: '编辑产品管理', group: '采购管理' }, + { key: 'supplier.view', label: '查看供应商管理', group: '采购管理' }, + { key: 'supplier.edit', label: '编辑供应商管理', group: '采购管理' }, + { key: 'cleanup.view', label: '合同清洗中心', group: '采购管理' }, + + /* 系统管理 */ + { key: 'org.manage', label: '组织管理', group: '系统管理' }, + /* 组织与品牌(改公司名 / 组织代码 / 描述)单独成点,与「组织管理」解耦 —— + Wayne 2026-09-15 的口径:这块**只允许高管改**,所以它得能单独勾给「公司高管」组, + 而不是搭在系统管理权限上。前端设置页分类、PATCH /api/organizations/:id 与 + PUT /api/branding 都认这一个点。 */ + { key: 'branding.manage', label: '组织与品牌(公司信息)', group: '系统管理' }, + { key: 'user.manage', label: '用户管理', group: '系统管理' }, + { key: 'perm.manage', label: '权限组管理', group: '系统管理' }, + { key: 'ai.manage', label: '智能配置(大模型接入与用途分配)', group: '系统管理' }, + { key: 'audit.view', label: '审计日志', group: '系统管理' } +] + +const PERMISSION_KEYS = PERMISSIONS.map(item => item.key) + +/* 角色清单:Wayne 列举的四个业务角色 + 系统管理员。权限组管理页的「角色指派」用它做候选。 */ +const ROLES = [ + { value: 'admin', label: '系统管理员' }, + { value: 'chairman', label: '董事长' }, + { value: 'general_manager', label: '总经理' }, + { value: 'sales_manager', label: '销售经理' }, + { value: 'sales_rep', label: '销售业务员' } +] + +/* 开箱默认权限组。仅在首次建库/首次运行时写入,之后以库里的为准。 */ +const DEFAULT_PERMISSION_GROUPS = [ + { + name: '超级管理员组', + description: '全部权限,含组织、用户与权限组管理', + is_system: 1, + permissions: PERMISSION_KEYS, + /* 只挂 admin。董事长 / 总经理改走下面的「公司高管」组 —— + 一个角色同时只能属于一个组(role_permission_groups.role 是主键), + 让他们留在超管组就等于「高管 = 系统管理员」,与 Wayne 2026-09-15 的分权口径冲突。 */ + roles: ['admin'] + }, + { + name: '销售管理组', + description: '业务全流程可读可写,可维护客户、产品与供应商,无系统管理权限', + is_system: 0, + /* 注意:`customer.view_all` 必须显式排除 —— 它不含 .manage 后缀, + 否则会被这个过滤器顺带带上,销售经理就能看到全公司的客户了。 + `branding.manage` 反过来说:它**靠** .manage 后缀被自动排除, + 销售经理永远拿不到改公司信息的权限(Wayne 口径:只有高管能改)。 */ + permissions: PERMISSION_KEYS.filter(key => + !key.endsWith('.manage') && key !== 'audit.view' && key !== 'customer.view_all'), + roles: ['sales_manager'] + }, + { + name: '销售业务组', + description: '基础业务操作:维护自己名下的客户与询盘报价,产品只读', + is_system: 0, + permissions: [ + 'dashboard.view', + 'inquiry.view', 'inquiry.edit', + 'quote.view', 'quote.edit', + 'contract.view', + 'purchase.view', + 'order.view', + 'customer.view', 'customer.edit', + 'prospect.view', 'pool.view', 'pool.claim', + 'product.view', 'supplier.view', 'cleanup.view' + ], + roles: ['sales_rep'] + }, + { + name: '公司高管', + description: '董事长 / 总经理:除用户、权限组、智能配置与系统日志外的全部权限,含改公司信息', + is_system: 0, + /* 与线上库(2026-09-15)一致:全量权限里摘掉四个「系统管理员专属」的点。 + user.manage / perm.manage 是账号治理权,ai.manage 会看到所有模型密钥, + audit.view 能看到全公司的操作轨迹 —— Wayne 口径是「系统日志仅系统管理员可见」。 */ + permissions: PERMISSION_KEYS.filter(key => + !['user.manage', 'perm.manage', 'ai.manage', 'audit.view'].includes(key)), + roles: ['chairman', 'general_manager'] + } +] + +module.exports = { PERMISSIONS, PERMISSION_KEYS, ROLES, DEFAULT_PERMISSION_GROUPS } diff --git a/backend/lib/skuCatalog.js b/backend/lib/skuCatalog.js new file mode 100644 index 0000000..b1608f9 --- /dev/null +++ b/backend/lib/skuCatalog.js @@ -0,0 +1,297 @@ +/** + * 产品目录(SKU 模块)的本地数据层。 + * + * 这块原先是独立的 sku-manager 服务(3300),happy 通过 /api/sku 代理访问它。 + * 合并后数据直接落在 trade.db:少一个进程、少一个端口,开源部署只跑 4300 + 5300。 + * + * 表结构沿用 sku-manager 那套(products / product_groups / suppliers / item_attributes / + * product_images / price_history / cart_items),列名保持一致 —— 迁移是按原 id 整表搬过来的, + * 所以这里的外键关系与历史数据完全对得上,不需要任何 id 映射。 + */ +const crypto = require('crypto'); +const db = require('../db/db'); + +/* --------------------------------- 规范化 --------------------------------- */ + +/** + * 货号规范化:去掉空格、连字符、下划线、斜杠、点,再转大写。 + * 合同里的「GD-100F」和产品库里的「GD100F」必须归到同一条。 + */ +function normalizeSku(raw) { + if (!raw) return null; + return String(raw).toUpperCase().replace(/[\s\-_./]/g, '').trim(); +} + +/** + * 产品归组键:同一单品的不同尺寸归为一个产品。 + * 只剥「尺寸/规格」类字样(8x10、200mm、1/2"、6寸),件数(6件套)保留 —— + * 套装件数不同是不同的产品。 + */ +function groupKey(name) { + if (!name) return null; + let s = String(name).replace(/\s+/g, ''); + s = s.replace(/\d+(\.\d+)?[x×*]\d+(\.\d+)?(mm|cm)?/gi, ''); + s = s.replace(/\d+\/\d+(["″”寸]|英寸)?/g, ''); + s = s.replace(/\d+(\.\d+)?(mm|cm|寸|英寸|inch|in|"|″|”|米)/gi, ''); + s = s.replace(/[-—–()()]/g, ''); + const key = s.toUpperCase(); + return key || String(name).trim().toUpperCase(); +} + +/* ------------------------------ 二进制存储 ------------------------------ */ + +const MIME_BY_EXT = { + '.pdf': 'application/pdf', + '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + '.xls': 'application/vnd.ms-excel', + '.csv': 'text/csv', + '.txt': 'text/plain', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.webp': 'image/webp', + '.gif': 'image/gif', + '.bmp': 'image/bmp' +}; + +function mimeFor(name) { + const i = String(name).lastIndexOf('.'); + return (i >= 0 && MIME_BY_EXT[String(name).slice(i).toLowerCase()]) || 'application/octet-stream'; +} + +const syncImageStmt = db.prepare( + `INSERT INTO product_image_files (image_id, bytes, size, stored_at) + VALUES (@image_id, @bytes, @size, @stored_at) + ON CONFLICT(image_id) DO UPDATE SET bytes = excluded.bytes, size = excluded.size, stored_at = excluded.stored_at` +); +const getImageStmt = db.prepare('SELECT bytes, size FROM product_image_files WHERE image_id = ?'); +const delImageStmt = db.prepare('DELETE FROM product_image_files WHERE image_id = ?'); + +const storeImage = (imageId, buf) => + syncImageStmt.run({ image_id: imageId, bytes: buf, size: buf.length, stored_at: new Date().toISOString() }); + +const getImage = (imageId) => getImageStmt.get(imageId); +const deleteImage = (imageId) => delImageStmt.run(imageId); + +/** 批量删图(批量删产品时用),避免逐条 delete。 */ +function deleteImagesFor(imageIds) { + if (!imageIds.length) return; + db.prepare(`DELETE FROM product_image_files WHERE image_id IN (${imageIds.map(() => '?').join(',')})`) + .run(...imageIds); +} + +/* ------------------------------- 合同原件 ------------------------------- */ + +const putContractStmt = db.prepare( + `INSERT INTO contract_files (contract_id, filename, mime, size, sha256, bytes, stored_at) + VALUES (@contract_id, @filename, @mime, @size, @sha256, @bytes, @stored_at) + ON CONFLICT(contract_id) DO UPDATE SET + filename = excluded.filename, mime = excluded.mime, size = excluded.size, + sha256 = excluded.sha256, bytes = excluded.bytes, stored_at = excluded.stored_at` +); + +/** 把一份合同原件收进库。返回 null 表示没收到(不算致命,解析结果才是主产物)。 */ +function storeContractFile(contractId, bytes, displayName) { + if (!bytes || !bytes.length) return null; + const filename = displayName || `contract-${contractId}`; + putContractStmt.run({ + contract_id: contractId, + filename, + mime: mimeFor(filename), + size: bytes.length, + sha256: crypto.createHash('sha256').update(bytes).digest('hex'), + bytes, + stored_at: new Date().toISOString() + }); + return { size: bytes.length }; +} + +const getContractFile = (contractId) => + db.prepare('SELECT filename, mime, size, bytes FROM contract_files WHERE contract_id = ?').get(contractId); + +/* --------------------------------- 参数 --------------------------------- */ + +const getItemAttrs = db.prepare( + 'SELECT id, name, value, sort FROM item_attributes WHERE sku = ? ORDER BY sort, id' +); +const getGroupAttrs = db.prepare( + 'SELECT id, name, value, sort FROM item_attributes WHERE group_id = ? AND sku IS NULL ORDER BY sort, id' +); + +/** 整组替换参数(货号级或产品级)。传入 [{name, value}],空名跳过。 */ +function replaceAttrs({ sku = null, groupId = null }, attrs) { + if (!Array.isArray(attrs)) return; + db.transaction(() => { + if (sku) db.prepare('DELETE FROM item_attributes WHERE sku = ?').run(sku); + else db.prepare('DELETE FROM item_attributes WHERE group_id = ? AND sku IS NULL').run(groupId); + const ins = db.prepare( + 'INSERT INTO item_attributes (group_id, sku, name, value, sort) VALUES (@group_id, @sku, @name, @value, @sort)' + ); + attrs.forEach((a, i) => { + const name = String(a?.name || '').trim(); + if (!name) return; + ins.run({ group_id: sku ? null : groupId, sku, name, value: String(a?.value ?? '').trim() || null, sort: i }); + }); + })(); +} + +/** 产品组变空后清理掉,避免列表里留下没有货号的壳(有图片的保留)。 */ +function cleanupGroupIfEmpty(groupId) { + if (!groupId) return; + const hasItems = db.prepare('SELECT 1 FROM products WHERE group_id = ? LIMIT 1').get(groupId); + const hasImages = db.prepare('SELECT 1 FROM product_images WHERE group_id = ? LIMIT 1').get(groupId); + if (!hasItems && !hasImages) { + db.prepare('DELETE FROM item_attributes WHERE group_id = ?').run(groupId); + db.prepare('DELETE FROM product_groups WHERE id = ?').run(groupId); + } +} + +/* --------------------------------- 货号 --------------------------------- */ + +function validatePriceMoq(b) { + const price = b.price === '' || b.price == null ? null : Number(b.price); + if (price !== null && (!Number.isFinite(price) || price < 0)) return { error: '采购价必须是非负数字' }; + const moq = b.moq === '' || b.moq == null ? null : Number(b.moq); + if (moq !== null && (!Number.isFinite(moq) || moq < 0)) return { error: '起订量必须是非负数字' }; + return { price, moq }; +} + +/** + * 新增货号。 + * + * 货号重复**不当错误** —— 产品库是组织共享的,谁都能上传自己那一版: + * 撞号时落成兄弟副本 `-COPY2 / -COPY3 …`,display_sku 加「-副本N」, + * 并用 duplicated_from 记住它复制自哪个货号,界面上标出来,避免两条同名货号看着像脏数据。 + * 无论是否重复都记 uploader / org_id;手工价同时写 changed_by,保证「谁传的、谁改的价」可追。 + */ +function insertItem(b, groupId, supplierId, actor = null, actorOrg = null, options = {}) { + const displaySku = String(b.sku || '').trim(); + const base = normalizeSku(displaySku); + if (!base) return { error: '货号不能为空' }; + const v = validatePriceMoq(b); + if (v.error) return v; + + const taken = (candidate) => !!db.prepare('SELECT sku FROM products WHERE sku = ?').get(candidate); + let sku = base; + let display = displaySku; + let duplicatedFrom = null; + if (taken(base)) { + duplicatedFrom = base; + let n = 2; + while (taken(`${base}-COPY${n}`)) n += 1; + sku = `${base}-COPY${n}`; + display = `${displaySku}-副本${n}`; + } + + const now = new Date().toISOString(); + /* options 只在「带来源的批量导入」里传(目前是合同清洗中心的入库): + sourceContract —— 价格出处,只记合同**文件名**(Wayne 2026-09-16:不记路径、不记任务号) + parseMethod —— 'manual' | 'rules' | 'ai',用来区分这条货号是手录还是合同带进来的 + currency —— 新建时按合同的币种落库;不传仍然是 CNY(产品库原有口径) + ⚠️ 更新已有货号时**不动 currency** —— 汇率口径牵一发动全身,不在这里顺手改。 */ + const sourceContract = options.sourceContract || null; + const parseMethod = options.parseMethod || 'manual'; + const currency = String(options.currency || 'CNY').toUpperCase(); + db.transaction(() => { + db.prepare( + `INSERT INTO products (sku, display_sku, name, spec, price, currency, moq, description, + supplier_id, contract_id, group_id, parse_method, + last_updated, source_contract, confidence, + uploader, org_id, duplicated_from, created_at, updated_at) + VALUES (@sku, @display_sku, @name, @spec, @price, @currency, @moq, @description, + @supplier_id, NULL, @group_id, @parse_method, @now, @source_contract, 'high', + @uploader, @org_id, @duplicated_from, @now, @now)` + ).run({ + sku, + display_sku: display, + name: b.name?.trim() || null, + spec: b.spec?.trim() || null, + price: v.price, + moq: v.moq, + currency, + description: b.description?.trim() || null, + supplier_id: supplierId, + group_id: groupId, + parse_method: parseMethod, + source_contract: sourceContract, + now, + uploader: actor, + org_id: actorOrg, + duplicated_from: duplicatedFrom + }); + /* 手工价格同样留痕,保证「每个价格都能追到出处」这条不破功。 + 带来源时(合同入库)把合同文件名当出处写进去,手工录入才写「手工录入」。 */ + if (v.price !== null) { + db.prepare( + `INSERT INTO price_history (sku, old_price, new_price, old_moq, new_moq, currency, + supplier_id, source_contract, confidence, changed_at, changed_by) + VALUES (@sku, NULL, @price, NULL, @moq, @currency, @supplier_id, @source_contract, 'high', @now, @changed_by)` + ).run({ + sku, + price: v.price, + moq: v.moq, + currency, + supplier_id: supplierId, + source_contract: sourceContract || '手工录入', + now, + changed_by: actor + }); + } + })(); + + if (Array.isArray(b.attributes)) replaceAttrs({ sku }, b.attributes); + return { sku, displaySku: display, duplicatedFrom }; +} + +/* ------------------------------- 产品解析 ------------------------------- */ + +/** + * 解析货号,返回统一的 { product, packaging } 视图。 + * + * 合并前这里要先打 SKU 服务、失败再回退本地镜像(镜像只兜底,绝不拿过期价去报价)。 + * 现在数据就在本库,直接查即可 —— 单表主键命中,比原来走一趟 HTTP 快得多。 + * packaging 始终取自本地 product_packaging(询盘/报价用的包装快照来源)。 + */ +function resolveProduct(sku) { + const raw = String(sku || '').trim(); + if (!raw) throw new Error('SKU 不能为空'); + + const key = normalizeSku(raw); + const row = db.prepare( + `SELECT p.*, s.name AS supplier_name FROM products p + LEFT JOIN suppliers s ON s.id = p.supplier_id WHERE p.sku = ?` + ).get(key); + if (!row) throw new Error(`产品 SKU 不存在:${raw}`); + + const product = { + ...row, + sku: row.sku, + display_sku: row.display_sku || row.sku, + name: row.name || row.name_en || row.sku, + name_en: row.name_en || null, + price: row.price, + currency: row.currency || 'CNY', + moq: row.moq === undefined ? null : row.moq + }; + const packaging = db.prepare('SELECT * FROM product_packaging WHERE sku = ?').get(row.sku) || null; + return { product, packaging }; +} + +module.exports = { + normalizeSku, + groupKey, + mimeFor, + storeImage, + getImage, + deleteImage, + deleteImagesFor, + storeContractFile, + getContractFile, + getItemAttrs, + getGroupAttrs, + replaceAttrs, + cleanupGroupIfEmpty, + validatePriceMoq, + insertItem, + resolveProduct +}; diff --git a/backend/package-lock.json b/backend/package-lock.json index ce16b44..acbcd52 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -12,7 +12,8 @@ "better-sqlite3-multiple-ciphers": "^13.0.3", "cors": "^2.8.5", "express": "^4.18.2", - "multer": "^1.4.5-lts.1" + "multer": "^1.4.5-lts.1", + "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz" }, "devDependencies": { "nodemon": "^3.0.1" @@ -1420,6 +1421,18 @@ "node": ">= 0.8" } }, + "node_modules/xlsx": { + "version": "0.20.3", + "resolved": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", + "integrity": "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==", + "license": "Apache-2.0", + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index 298e328..7a21b2c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,14 +7,19 @@ "start": "node server.js", "dev": "nodemon server.js" }, - "keywords": ["trade", "crm", "supply-chain"], + "keywords": [ + "trade", + "crm", + "supply-chain" + ], "author": "", "license": "ISC", "dependencies": { "better-sqlite3-multiple-ciphers": "^13.0.3", "cors": "^2.8.5", "express": "^4.18.2", - "multer": "^1.4.5-lts.1" + "multer": "^1.4.5-lts.1", + "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz" }, "devDependencies": { "nodemon": "^3.0.1" diff --git a/backend/routes/access.js b/backend/routes/access.js new file mode 100644 index 0000000..9a3ad0c --- /dev/null +++ b/backend/routes/access.js @@ -0,0 +1,464 @@ +/** + * 组织与权限组(多用户阶段的权限中枢)。 + * 挂载点:/api + * + * 口径(Wayne 2026-09-14 定): + * - 角色只是职位标签,本身不带权限; + * - 权限组 = 一批权限点;把角色指派进某个组,该角色下所有用户即获得这组权限。 + * 所以「角色 → 组」是唯一的授权入口,permission_group_permissions 是唯一的权限范围来源。 + */ +const express = require('express'); +const db = require('../db/db'); +const auth = require('../lib/auth'); +const branding = require('../lib/branding'); +const { PERMISSIONS, PERMISSION_KEYS, ROLES } = require('../lib/permissions'); + +const router = express.Router(); +const ROLE_VALUES = ROLES.map(item => item.value); +const KEY_SET = new Set(PERMISSION_KEYS); + +function badRequest(message) { + const error = new Error(message); + error.statusCode = 400; + return error; +} +function fail(res, error) { + const message = error?.message || '请求处理失败'; + const status = error?.statusCode + || (/必须|不能为空|不存在|无效|格式|已存在|不可|请先/.test(message) ? 400 : 500); + res.status(status).json({ error: message }); +} + +/** 校验并归一化权限点数组 */ +function normalizePermissions(input) { + if (input === undefined) return undefined; + if (!Array.isArray(input)) throw badRequest('权限范围格式不正确'); + const list = [...new Set(input.map(key => String(key).trim()).filter(Boolean))]; + const unknown = list.filter(key => !KEY_SET.has(key)); + if (unknown.length) throw badRequest(`未知的权限点:${unknown.join('、')}`); + return list; +} + +/** 校验并归一化角色数组 */ +function normalizeRoles(input) { + if (input === undefined) return undefined; + if (!Array.isArray(input)) throw badRequest('角色指派格式不正确'); + const list = [...new Set(input.map(role => String(role).trim()).filter(Boolean))]; + const unknown = list.filter(role => !ROLE_VALUES.includes(role)); + if (unknown.length) throw badRequest(`未知的角色:${unknown.join('、')}`); + return list; +} + +function groupDetail(row) { + const permissions = db.prepare('SELECT permission_key FROM permission_group_permissions WHERE group_id = ?') + .all(row.id).map(item => item.permission_key); + const roles = db.prepare('SELECT role FROM role_permission_groups WHERE group_id = ?') + .all(row.id).map(item => item.role); + return { + id: row.id, + name: row.name, + description: row.description || '', + is_system: !!row.is_system, + permissions, + roles, + created_at: row.created_at, + updated_at: row.updated_at + }; +} + +/** 写入某个组的权限范围(整组替换) */ +function replacePermissions(groupId, permissions) { + db.prepare('DELETE FROM permission_group_permissions WHERE group_id = ?').run(groupId); + const insert = db.prepare('INSERT OR IGNORE INTO permission_group_permissions (group_id, permission_key) VALUES (?, ?)'); + for (const key of permissions) insert.run(groupId, key); +} + +/** + * 写入某个组的角色指派。 + * 先把原本指向本组、但不在新名单里的角色解绑(role 表主键唯一,只能改指处), + * 再把新名单里的角色指到本组。 + */ +function replaceRoles(groupId, roles, nowIso) { + const current = db.prepare('SELECT role FROM role_permission_groups WHERE group_id = ?').all(groupId).map(row => row.role); + const keep = new Set(roles); + for (const role of current) { + if (!keep.has(role)) db.prepare('DELETE FROM role_permission_groups WHERE role = ?').run(role); + } + const assign = db.prepare('INSERT OR REPLACE INTO role_permission_groups (role, group_id, updated_at) VALUES (?, ?, ?)'); + for (const role of roles) assign.run(role, groupId, nowIso); +} + +/* ------------------------------------------------------------ 权限清单 */ + +/** GET /api/permissions —— 权限点清单 + 可选角色(权限组管理页的左栏) */ +router.get('/permissions', auth.requireAuth, (req, res) => { + try { + const groups = []; + for (const item of PERMISSIONS) { + let bucket = groups.find(group => group.title === item.group); + if (!bucket) { bucket = { title: item.group, items: [] }; groups.push(bucket); } + bucket.items.push({ key: item.key, label: item.label }); + } + res.json({ permissions: PERMISSIONS, groups, roles: ROLES }); + } catch (error) { + fail(res, error); + } +}); + +/** GET /api/roles —— 角色与各自所在的权限组(指派一览) */ +router.get('/roles', auth.requireAuth, (req, res) => { + try { + const rows = db.prepare(` + SELECT r.role, r.group_id, g.name AS group_name + FROM role_permission_groups r JOIN permission_groups g ON g.id = r.group_id + `).all(); + const map = new Map(rows.map(row => [row.role, row])); + res.json({ + roles: ROLES.map(item => ({ + ...item, + group_id: map.get(item.value)?.group_id || null, + group_name: map.get(item.value)?.group_name || null + })) + }); + } catch (error) { + fail(res, error); + } +}); + +/* ------------------------------------------------------------ 权限组 */ + +router.get('/permission-groups', auth.requirePermission('perm.manage'), (req, res) => { + try { + const rows = db.prepare('SELECT * FROM permission_groups ORDER BY is_system DESC, id ASC').all(); + res.json({ groups: rows.map(groupDetail) }); + } catch (error) { + fail(res, error); + } +}); + +router.post('/permission-groups', auth.requirePermission('perm.manage'), (req, res) => { + try { + const name = String(req.body?.name || '').trim(); + if (!name) throw badRequest('权限组名称不能为空'); + if (db.prepare('SELECT id FROM permission_groups WHERE name = ?').get(name)) throw badRequest('该权限组名称已存在'); + const permissions = normalizePermissions(req.body?.permissions) || []; + const roles = normalizeRoles(req.body?.roles) || []; + + const now = new Date().toISOString(); + const result = db.prepare('INSERT INTO permission_groups (name, description, is_system, created_at) VALUES (?, ?, 0, ?)') + .run(name, String(req.body?.description || '').trim() || null, now); + replacePermissions(result.lastInsertRowid, permissions); + replaceRoles(result.lastInsertRowid, roles, now); + + auth.audit({ username: req.user.username, action: 'permission_group_create', detail: `新建权限组「${name}」`, req }); + const row = db.prepare('SELECT * FROM permission_groups WHERE id = ?').get(result.lastInsertRowid); + res.status(201).json({ group: groupDetail(row), message: '权限组已创建' }); + } catch (error) { + fail(res, error); + } +}); + +router.patch('/permission-groups/:id', auth.requirePermission('perm.manage'), (req, res) => { + try { + const id = Number(req.params.id); + const row = db.prepare('SELECT * FROM permission_groups WHERE id = ?').get(id); + if (!row) throw badRequest('权限组不存在'); + + const now = new Date().toISOString(); + const fields = []; + const values = []; + if (req.body?.name !== undefined) { + const name = String(req.body.name || '').trim(); + if (!name) throw badRequest('权限组名称不能为空'); + const clash = db.prepare('SELECT id FROM permission_groups WHERE name = ? AND id != ?').get(name, id); + if (clash) throw badRequest('该权限组名称已存在'); + fields.push('name = ?'); values.push(name); + } + if (req.body?.description !== undefined) { + fields.push('description = ?'); values.push(String(req.body.description || '').trim() || null); + } + if (fields.length) { + fields.push('updated_at = ?'); values.push(now); values.push(id); + db.prepare(`UPDATE permission_groups SET ${fields.join(', ')} WHERE id = ?`).run(...values); + } + + const permissions = normalizePermissions(req.body?.permissions); + if (permissions) replacePermissions(id, permissions); + const roles = normalizeRoles(req.body?.roles); + if (roles) replaceRoles(id, roles, now); + + auth.audit({ username: req.user.username, action: 'permission_group_update', detail: `更新权限组「${row.name}」`, req }); + const updated = db.prepare('SELECT * FROM permission_groups WHERE id = ?').get(id); + res.json({ group: groupDetail(updated), message: '权限组已保存' }); + } catch (error) { + fail(res, error); + } +}); + +router.delete('/permission-groups/:id', auth.requirePermission('perm.manage'), (req, res) => { + try { + const id = Number(req.params.id); + const row = db.prepare('SELECT * FROM permission_groups WHERE id = ?').get(id); + if (!row) throw badRequest('权限组不存在'); + if (row.is_system) throw badRequest('系统内置权限组不可删除'); + const occupied = db.prepare('SELECT COUNT(*) AS c FROM role_permission_groups WHERE group_id = ?').get(id).c; + if (occupied > 0) throw badRequest('该权限组下仍指派有角色,请先把角色改派到其它组'); + + db.prepare('DELETE FROM permission_group_permissions WHERE group_id = ?').run(id); + db.prepare('DELETE FROM permission_groups WHERE id = ?').run(id); + auth.audit({ username: req.user.username, action: 'permission_group_delete', detail: `删除权限组「${row.name}」`, req }); + res.json({ ok: true, message: '权限组已删除' }); + } catch (error) { + fail(res, error); + } +}); + +/* ------------------------------------------------------------ 组织 */ + +router.get('/organizations', auth.requireAuth, (req, res) => { + try { + const rows = db.prepare(` + SELECT o.*, (SELECT COUNT(*) FROM users u WHERE u.org_id = o.id) AS member_count + FROM organizations o ORDER BY o.id ASC + `).all(); + res.json({ organizations: rows }); + } catch (error) { + fail(res, error); + } +}); + +router.post('/organizations', auth.requirePermission('org.manage'), (req, res) => { + try { + const name = String(req.body?.name || '').trim(); + if (!name) throw badRequest('组织名称不能为空'); + if (db.prepare('SELECT id FROM organizations WHERE name = ?').get(name)) throw badRequest('该组织名称已存在'); + const now = new Date().toISOString(); + const result = db.prepare('INSERT INTO organizations (name, code, description, created_at) VALUES (?, ?, ?, ?)') + .run(name, String(req.body?.code || '').trim() || null, String(req.body?.description || '').trim() || null, now); + auth.audit({ username: req.user.username, action: 'organization_create', detail: `新建组织「${name}」`, req }); + res.status(201).json({ organization: db.prepare('SELECT * FROM organizations WHERE id = ?').get(result.lastInsertRowid) }); + } catch (error) { + fail(res, error); + } +}); + +/* ------------------------------------------------------------ 组织改名与系统标识 */ + +/** + * PATCH /api/organizations/:id —— 改组织名/代码/描述(branding.manage) + * 组织名 = 部署方自己的公司名,可自由改;它只作业务字段用(资源边界 + 下拉显示), + * **不会**影响界面上的系统名(系统名固定,见 lib/branding.js)。 + * + * ⚠️ 权限点从 `org.manage` 换成 `branding.manage`(Wayne 2026-09-15): + * 这块**只允许高管改**,所以拆成独立权限点,只勾给「公司高管」组 + 超级管理员组, + * 销售经理/业务员永远拿不到。 + */ +router.patch('/organizations/:id', auth.requirePermission('branding.manage'), (req, res) => { + try { + const before = db.prepare('SELECT id, name FROM organizations WHERE id = ?').get(Number(req.params.id)); + if (!before) throw badRequest('组织不存在'); + const organization = branding.updateOrg(req.params.id, req.body || {}); + auth.audit({ + username: req.user.username, + action: 'organization_update', + detail: `组织「${before.name}」→「${organization.name}」`, + req + }); + res.json({ organization }); + } catch (error) { + fail(res, error); + } +}); + +/** GET /api/branding —— 当前系统标识 + 公司名(登录即可读,设置页要用) */ +router.get('/branding', auth.requireAuth, (req, res) => { + try { + res.json(branding.getBranding()); + } catch (error) { + fail(res, error); + } +}); + +/** + * PUT /api/branding —— 已停用:系统名是作者固定署名,任何角色都改不了。 + * 保留这条路由是为了给调用方一个明确的 403 说明,而不是含糊的 404。 + * 想换公司门面的,请改组织名(PATCH /api/organizations/:id)。 + */ +router.put('/branding', auth.requirePermission('branding.manage'), (req, res) => { + res.status(403).json({ + error: `「${branding.SYSTEM.name}」是作者固定署名,不可修改;如需换成贵公司的名字,请修改组织名称。` + }); +}); + +/* ------------------------------------------------------------ 用户 */ + +/** 用户行 → 输出对象(绝不带 password_hash),附带角色/权限组/组织的可读名 */ +function userDetail(row) { + const roleMeta = ROLES.find(item => item.value === row.role); + const group = db.prepare(` + SELECT g.id, g.name FROM role_permission_groups r + JOIN permission_groups g ON g.id = r.group_id WHERE r.role = ? + `).get(row.role || '') || null; + const org = row.org_id + ? db.prepare('SELECT id, name, code FROM organizations WHERE id = ?').get(row.org_id) + : null; + return { + id: row.id, + username: row.username, + display_name: row.display_name || row.username, + email: row.email || '', + phone: row.phone || '', + role: row.role || '', + role_label: roleMeta?.label || row.role || '—', + position: row.position || '', + org_id: row.org_id || null, + org_name: org?.name || '', + permission_group_name: group?.name || '', + is_active: !!row.is_active, + created_at: row.created_at, + last_login_at: row.last_login_at + }; +} + +function normalizeRole(value) { + const role = String(value || '').trim(); + if (!ROLE_VALUES.includes(role)) throw badRequest(`角色不正确(可选:${ROLE_VALUES.join('、')})`); + return role; +} + +function normalizeOrgId(value) { + if (value === null || value === undefined || value === '') return null; + const id = Number(value); + const org = db.prepare('SELECT id FROM organizations WHERE id = ?').get(id); + if (!org) throw badRequest('所属组织不存在'); + return id; +} + +/** GET /api/users —— 用户列表(user.manage) */ +router.get('/users', auth.requirePermission('user.manage'), (req, res) => { + try { + const rows = db.prepare('SELECT * FROM users ORDER BY id ASC').all(); + res.json({ users: rows.map(userDetail) }); + } catch (error) { + fail(res, error); + } +}); + +/** POST /api/users —— 新建用户(user.manage) */ +router.post('/users', auth.requirePermission('user.manage'), (req, res) => { + try { + const username = String(req.body?.username || '').trim(); + if (!username) throw badRequest('登录账号不能为空'); + if (!/^[a-zA-Z0-9_.-]{2,32}$/.test(username)) { + throw badRequest('账号只能是 2–32 位字母 / 数字 / 下划线 / 点 / 横线'); + } + if (db.prepare('SELECT id FROM users WHERE username = ?').get(username)) throw badRequest('该账号已存在'); + const password = String(req.body?.password || ''); + if (password.length < 6) throw badRequest('密码至少 6 位'); + const role = normalizeRole(req.body?.role); + const orgId = normalizeOrgId(req.body?.org_id); + + const now = new Date().toISOString(); + const result = db.prepare(` + INSERT INTO users (username, password_hash, display_name, email, phone, role, position, org_id, is_active, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?) + `).run( + username, + auth.hashPassword(password), + String(req.body?.display_name || '').trim() || null, + String(req.body?.email || '').trim() || null, + String(req.body?.phone || '').trim() || null, + role, + String(req.body?.position || '').trim() || null, + orgId, + now + ); + auth.audit({ username: req.user.username, action: 'user_create', detail: `新建用户「${username}」(${role})`, req }); + const row = db.prepare('SELECT * FROM users WHERE id = ?').get(result.lastInsertRowid); + res.status(201).json({ user: userDetail(row), message: '用户已创建' }); + } catch (error) { + fail(res, error); + } +}); + +/** PATCH /api/users/:id —— 编辑用户 / 重置密码 / 停用(user.manage) */ +router.patch('/users/:id', auth.requirePermission('user.manage'), (req, res) => { + try { + const id = Number(req.params.id); + const row = db.prepare('SELECT * FROM users WHERE id = ?').get(id); + if (!row) throw badRequest('用户不存在'); + + const body = req.body || {}; + const isSelf = row.id === req.user.id; + + /* 自己的账号:不许改角色(防止把自己锁在门外)、不许停用 */ + if (isSelf && body.role !== undefined && String(body.role) !== row.role) { + throw badRequest('不能修改自己的角色'); + } + if (isSelf && body.is_active !== undefined && !body.is_active) { + throw badRequest('不能停用自己的账号'); + } + + const fields = []; + const values = []; + if (body.display_name !== undefined) { + fields.push('display_name = ?'); values.push(String(body.display_name || '').trim() || null); + } + if (body.email !== undefined) { + fields.push('email = ?'); values.push(String(body.email || '').trim() || null); + } + if (body.phone !== undefined) { + fields.push('phone = ?'); values.push(String(body.phone || '').trim() || null); + } + if (body.position !== undefined) { + fields.push('position = ?'); values.push(String(body.position || '').trim() || null); + } + if (body.org_id !== undefined) { + fields.push('org_id = ?'); values.push(normalizeOrgId(body.org_id)); + } + if (body.role !== undefined) { + const role = normalizeRole(body.role); + if (row.role === 'admin' && role !== 'admin') { + /* 防止把最后一个系统管理员改成普通角色 */ + const adminCount = db.prepare("SELECT COUNT(*) AS c FROM users WHERE role = 'admin' AND is_active = 1").get().c; + if (adminCount <= 1) throw badRequest('系统至少要保留一名可用的系统管理员'); + } + fields.push('role = ?'); values.push(role); + } + if (body.is_active !== undefined) { + fields.push('is_active = ?'); values.push(body.is_active ? 1 : 0); + } + if (body.password !== undefined) { + const password = String(body.password || ''); + if (password.length < 6) throw badRequest('密码至少 6 位'); + fields.push('password_hash = ?'); values.push(auth.hashPassword(password)); + fields.push('failed_count = ?'); values.push(0); + fields.push('locked_until = ?'); values.push(null); + } + + if (!fields.length) throw badRequest('没有要更新的内容'); + const now = new Date().toISOString(); + fields.push('updated_at = ?'); values.push(now); values.push(id); + db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...values); + + /* 改密/停用后踢掉该用户除当前会话外的所有会话(当前管理员自己的会话只在做自改密码时保留) */ + if (body.password !== undefined || body.is_active === false) { + const keep = isSelf ? req.user.sid : null; + db.prepare('DELETE FROM sessions WHERE user_id = ? AND id != ?').run(id, keep || ''); + } + + const summary = [ + body.password !== undefined ? '重置密码' : null, + body.is_active !== undefined ? (body.is_active ? '启用' : '停用') : null, + body.role !== undefined && String(body.role) !== row.role ? `角色改为 ${body.role}` : null + ].filter(Boolean).join('、') || '更新资料'; + auth.audit({ username: req.user.username, action: 'user_update', detail: `${summary}:「${row.username}」`, req }); + + const updated = db.prepare('SELECT * FROM users WHERE id = ?').get(id); + res.json({ user: userDetail(updated), message: '用户已保存' }); + } catch (error) { + fail(res, error); + } +}); + +module.exports = router; diff --git a/backend/routes/ai.js b/backend/routes/ai.js new file mode 100644 index 0000000..27ae27f --- /dev/null +++ b/backend/routes/ai.js @@ -0,0 +1,212 @@ +/** + * 智能配置:多模型接入、连通性测试、模型清单、用途分配。 + * 挂载点:/api/ai + * + * 权限: + * - 接入配置的读与写都要 `ai.manage`(配置里有 base_url 与密钥打码,属系统管理面); + * - `POST /api/ai/chat` 只要登录 —— 它是给所有用户用的对话能力,不是管理动作。 + * + * 多模型并行(Wayne 2026-09-15): + * 可以同时接多家、每家多个模型,然后按「用途」把不同模型派给不同活 —— + * 合同解析用长文本强的,图片识别用多模态的,日常对话用便宜的。 + */ +const express = require('express'); +const auth = require('../lib/auth'); +const ai = require('../lib/aiProviders'); +const { MODES, PRESETS, PURPOSES, PURPOSE_KEYS } = require('../lib/aiPresets'); + +const router = express.Router(); + +function fail(res, error) { + const message = error?.message || '请求处理失败'; + const status = error?.statusCode + || (/不能为空|不正确|不存在|无效|格式|只能|超过/.test(message) ? 400 : 500); + res.status(status).json({ error: message }); +} + +/** GET /api/ai/config —— 一次拿全:预设、兼容模式、用途、已接入的服务商、用途分配 */ +router.get('/config', auth.requirePermission('ai.manage'), (req, res) => { + try { + res.json({ + presets: PRESETS, + modes: MODES, + purposes: PURPOSES, + providers: ai.listProviders().map(ai.publicProvider), + assignments: ai.listAssignments(), + overview: ai.overview() + }); + } catch (error) { + fail(res, error); + } +}); + +/** GET /api/ai/status —— 轻量状态:小皮这类普通用户界面用(不含任何配置细节) */ +router.get('/status', auth.requireAuth, (req, res) => { + try { + const resolved = ai.resolveAssignment('assistant_chat'); + res.json({ + configured: Boolean(resolved), + assistant: resolved ? { provider: resolved.provider.name, model: resolved.model } : null + }); + } catch (error) { + fail(res, error); + } +}); + +/** POST /api/ai/providers —— 新增一家模型服务 */ +router.post('/providers', auth.requirePermission('ai.manage'), (req, res) => { + try { + const provider = ai.upsertProvider(req.body || {}); + auth.audit({ + username: req.user.username, + action: 'ai_provider_create', + detail: `接入模型服务「${provider.name}」(${provider.base_url} · ${provider.mode})`, + req + }); + res.status(201).json({ provider: ai.publicProvider(provider) }); + } catch (error) { + fail(res, error); + } +}); + +/** PATCH /api/ai/providers/:id —— 改配置(密钥不回传,省略即保持原值) */ +router.patch('/providers/:id', auth.requirePermission('ai.manage'), (req, res) => { + try { + const provider = ai.upsertProvider(req.body || {}, req.params.id); + auth.audit({ + username: req.user.username, + action: 'ai_provider_update', + detail: `更新接入「${provider.name}」:${provider.enabled === false ? '停用' : '启用'} · 模型 ${provider.models.length} 个`, + req + }); + res.json({ provider: ai.publicProvider(provider) }); + } catch (error) { + fail(res, error); + } +}); + +/** DELETE /api/ai/providers/:id —— 移除接入(顺手清掉指向它的用途分配) */ +router.delete('/providers/:id', auth.requirePermission('ai.manage'), (req, res) => { + try { + const result = ai.removeProvider(req.params.id); + auth.audit({ + username: req.user.username, + action: 'ai_provider_delete', + detail: `移除接入「${result.removed.name}」${result.cleared_assignments ? `(同时清掉 ${result.cleared_assignments} 处用途分配)` : ''}`, + req + }); + res.json(result); + } catch (error) { + fail(res, error); + } +}); + +/** POST /api/ai/providers/:id/test —— 连通性测试(真发请求) */ +router.post('/providers/:id/test', auth.requirePermission('ai.manage'), async (req, res) => { + try { + const provider = ai.getProvider(req.params.id); + if (!provider) throw Object.assign(new Error('该模型接入不存在'), { statusCode: 400 }); + const result = await ai.testProvider(provider); + ai.rememberTest(provider.id, { + ok: result.ok, + at: result.at, + latency_ms: result.latency_ms, + detail: result.detail + }); + auth.audit({ + username: req.user.username, + action: 'ai_provider_test', + detail: `连通测试「${provider.name}」:${result.ok ? '通过' : '失败'} · ${result.detail}`, + req, + ok: result.ok ? 1 : 0 + }); + res.json({ result, provider: ai.publicProvider(ai.getProvider(provider.id)) }); + } catch (error) { + fail(res, error); + } +}); + +/** POST /api/ai/providers/:id/models —— 拉取可用模型(save=1 时顺便存进该接入) */ +router.post('/providers/:id/models', auth.requirePermission('ai.manage'), async (req, res) => { + try { + const provider = ai.getProvider(req.params.id); + if (!provider) throw Object.assign(new Error('该模型接入不存在'), { statusCode: 400 }); + const result = await ai.fetchModels(provider); + if (!result.ok && !result.models.length) { + return res.status(400).json({ error: result.error || '未能读取模型清单' }); + } + let saved = null; + if (String(req.body?.save || '') === '1' && result.models.length) { + saved = ai.publicProvider(ai.upsertProvider({ models: result.models }, provider.id)); + } + res.json({ models: result.models, source: result.source, detail: result.detail, provider: saved }); + } catch (error) { + fail(res, error); + } +}); + +/** + * PUT /api/ai/assignments —— 保存「用途 → 模型」分配。 + * 传 { assignments: { purpose: { provider_id, model } | null } },整体替换。 + */ +router.put('/assignments', auth.requirePermission('ai.manage'), (req, res) => { + try { + const input = req.body?.assignments; + if (input === undefined || input === null || typeof input !== 'object' || Array.isArray(input)) { + throw Object.assign(new Error('分配内容格式不正确'), { statusCode: 400 }); + } + const providers = ai.listProviders(); + const next = {}; + for (const [purpose, value] of Object.entries(input)) { + if (!PURPOSE_KEYS.includes(purpose)) { + throw Object.assign(new Error(`未知的用途:${purpose}`), { statusCode: 400 }); + } + if (value === null || value === undefined || value === '') continue; + const provider = providers.find(item => item.id === String(value.provider_id)); + if (!provider) throw Object.assign(new Error(`用途「${purpose}」指向的模型接入不存在`), { statusCode: 400 }); + const model = String(value.model || '').trim(); + if (!model) continue; + next[purpose] = { provider_id: provider.id, model }; + } + ai.saveAssignments(next); + auth.audit({ + username: req.user.username, + action: 'ai_assignment_update', + detail: `用途分配:${Object.entries(next).map(([k, v]) => `${k}→${v.model}`).join(',') || '(全部清空)'}`, + req + }); + res.json({ assignments: next, overview: ai.overview() }); + } catch (error) { + fail(res, error); + } +}); + +/** + * POST /api/ai/chat —— 按用途发起一次真实对话(试跑 / 小皮内部使用)。 + * body: { prompt | messages, purpose, temperature } + */ +router.post('/chat', auth.requireAuth, async (req, res) => { + try { + const purpose = String(req.body?.purpose || 'assistant_chat'); + if (!PURPOSE_KEYS.includes(purpose)) { + throw Object.assign(new Error(`未知的用途:${purpose}`), { statusCode: 400 }); + } + const messages = Array.isArray(req.body?.messages) && req.body.messages.length + ? req.body.messages.map(item => ({ role: item.role === 'assistant' ? 'assistant' : 'user', content: String(item.content || '') })) + : [{ role: 'user', content: String(req.body?.prompt || '').trim() }]; + if (!messages[0].content) throw Object.assign(new Error('请输入内容'), { statusCode: 400 }); + + const result = await ai.chatByPurpose(purpose, messages, { + temperature: req.body?.temperature + }); + if (!result) { + return res.status(409).json({ error: '还没有为这个用途分配模型,请先在「设置 → 智能配置」里接入并分配' }); + } + if (!result.ok) return res.status(502).json({ error: `${result.provider} 调用失败:${result.error}`, ...result }); + res.json(result); + } catch (error) { + fail(res, error); + } +}); + +module.exports = router; diff --git a/backend/routes/auth.js b/backend/routes/auth.js new file mode 100644 index 0000000..36e8551 --- /dev/null +++ b/backend/routes/auth.js @@ -0,0 +1,182 @@ +/** + * 账号与个人资料。 + * 挂载点:/api(路由内部自带 /auth/* 与 /me* 前缀) + * + * 免登录白名单在 server.js 的 /api 登录门里声明(health / auth\/ / system\/), + * 所以这里 /auth/login、/auth/logout、/auth/me 都可以在没有会话时访问。 + */ +const express = require('express'); +const db = require('../db/db'); +const auth = require('../lib/auth'); +const { ROLES } = require('../lib/permissions'); + +const router = express.Router(); +const ROLE_VALUES = ROLES.map(item => item.value); + +function badRequest(message) { + const error = new Error(message); + error.statusCode = 400; + return error; +} +function fail(res, error) { + const message = error?.message || '请求处理失败'; + const status = error?.statusCode + || (/必须|不能为空|不正确|不存在|不一致|格式|至少|已停用|已锁定/.test(message) ? 400 : 500); + res.status(status).json({ error: message }); +} + +/* ------------------------------------------------------------ 登录相关 */ + +/** POST /api/auth/login */ +router.post('/auth/login', (req, res) => { + try { + const username = String(req.body?.username || '').trim(); + const password = String(req.body?.password || ''); + if (!username) throw badRequest('请输入用户名'); + if (!password) throw badRequest('请输入密码'); + + const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username); + if (!user) { + auth.audit({ username, action: 'login_failed', detail: '用户名不存在', req, ok: 0 }); + throw badRequest('用户名或密码不正确'); + } + if (user.locked_until && new Date(user.locked_until).getTime() > Date.now()) { + const minutes = Math.max(1, Math.ceil((new Date(user.locked_until).getTime() - Date.now()) / 60000)); + auth.audit({ username, action: 'login_blocked', detail: '账号锁定中', req, ok: 0 }); + throw badRequest(`账号已锁定,请 ${minutes} 分钟后再试`); + } + if (!user.is_active) { + auth.audit({ username, action: 'login_blocked', detail: '账号已停用', req, ok: 0 }); + throw badRequest('账号已停用,请联系管理员'); + } + if (!auth.verifyPassword(password, user.password_hash)) { + const failed = Number(user.failed_count || 0) + 1; + const lockedUntil = failed >= auth.MAX_FAILED + ? new Date(Date.now() + auth.LOCK_MS).toISOString() + : null; + db.prepare('UPDATE users SET failed_count = ?, locked_until = ? WHERE id = ?').run(failed, lockedUntil, user.id); + auth.audit({ username, action: 'login_failed', detail: `密码错误(第 ${failed} 次)`, req, ok: 0 }); + throw badRequest(lockedUntil + ? `密码连续错误 ${failed} 次,账号已锁定 15 分钟` + : '用户名或密码不正确'); + } + + const now = new Date().toISOString(); + db.prepare('UPDATE users SET failed_count = 0, locked_until = NULL, last_login_at = ? WHERE id = ?').run(now, user.id); + const sessionId = auth.createSession(user.id, req); + auth.setSessionCookie(res, sessionId); + auth.audit({ username: user.username, action: 'login', detail: '登录成功', req }); + res.json({ user: auth.publicUser({ ...user, last_login_at: now }) }); + } catch (error) { + fail(res, error); + } +}); + +/** POST /api/auth/logout */ +router.post('/auth/logout', (req, res) => { + try { + if (req.user) { + auth.destroySession(req.user.sid); + auth.audit({ username: req.user.username, action: 'logout', detail: '退出登录', req }); + } + auth.clearSessionCookie(res); + res.json({ ok: true }); + } catch (error) { + fail(res, error); + } +}); + +/** GET /api/auth/me —— 前端启动时用它判断是否已登录 */ +router.get('/auth/me', (req, res) => { + if (!req.user) return res.status(401).json({ error: '请先登录' }); + res.json({ user: auth.publicUser(req.user) }); +}); + +/* ------------------------------------------------------------ 个人资料 */ + +const PROFILE_TEXT_FIELDS = [ + ['display_name', '显示名'], + ['email', '联系邮箱'], + ['phone', '电话'], + ['address', '地址'], + ['position', '职位'], + ['avatar', '头像'] +]; + +/** PATCH /api/me —— 保存个人资料 */ +router.patch('/me', (req, res) => { + try { + if (!req.user) return res.status(401).json({ error: '请先登录' }); + const fields = []; + const values = []; + + for (const [key, label] of PROFILE_TEXT_FIELDS) { + if (req.body?.[key] === undefined) continue; + const value = String(req.body[key] ?? '').trim(); + if (key === 'display_name' && !value) throw badRequest('显示名不能为空'); + if (key === 'email' && value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) { + throw badRequest('联系邮箱格式不正确'); + } + fields.push(`${key} = ?`); + values.push(value || null); + } + + if (req.body?.role !== undefined) { + const role = String(req.body.role || '').trim(); + if (!ROLE_VALUES.includes(role)) throw badRequest('角色取值不在允许范围内'); + fields.push('role = ?'); + values.push(role); + } + if (req.body?.org_id !== undefined) { + const orgId = Number(req.body.org_id); + if (!Number.isInteger(orgId) || !db.prepare('SELECT id FROM organizations WHERE id = ?').get(orgId)) { + throw badRequest('所属组织不存在'); + } + fields.push('org_id = ?'); + values.push(orgId); + } + + if (!fields.length) throw badRequest('没有需要保存的内容'); + fields.push('updated_at = ?'); + values.push(new Date().toISOString()); + values.push(req.user.id); + db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...values); + + const updated = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id); + auth.audit({ username: updated.username, action: 'profile_update', detail: '更新个人资料', req }); + res.json({ user: auth.publicUser(updated), message: '个人资料已保存' }); + } catch (error) { + fail(res, error); + } +}); + +/** PATCH /api/me/password —— 改密码:三段校验全过才落库 */ +router.patch('/me/password', (req, res) => { + try { + if (!req.user) return res.status(401).json({ error: '请先登录' }); + const current = String(req.body?.current_password || ''); + const next = String(req.body?.new_password || ''); + const confirm = String(req.body?.confirm_password || ''); + + if (!current) throw badRequest('请输入当前密码'); + if (!next) throw badRequest('请输入新密码'); + if (next.length < 6) throw badRequest('新密码至少 6 位'); + if (next !== confirm) throw badRequest('两次输入的新密码不一致'); + if (next === current) throw badRequest('新密码不能与当前密码相同'); + if (!auth.verifyPassword(current, req.user.password_hash)) { + auth.audit({ username: req.user.username, action: 'password_change_failed', detail: '当前密码不正确', req, ok: 0 }); + throw badRequest('当前密码不正确'); + } + + db.prepare('UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?') + .run(auth.hashPassword(next), new Date().toISOString(), req.user.id); + /* 保住当前会话,踢掉其它设备上已登录的会话 */ + db.prepare('DELETE FROM sessions WHERE user_id = ? AND id != ?').run(req.user.id, req.user.sid); + auth.audit({ username: req.user.username, action: 'password_change', detail: '修改密码成功', req }); + res.json({ ok: true, message: '密码已修改' }); + } catch (error) { + fail(res, error); + } +}); + +module.exports = router; diff --git a/backend/routes/contracts.js b/backend/routes/contracts.js new file mode 100644 index 0000000..3ae4ed2 --- /dev/null +++ b/backend/routes/contracts.js @@ -0,0 +1,431 @@ +/** + * 合同清洗中心:解析规则、AI 兜底解析、批量入库、原件下载。 + * 挂载点:/api/contracts(挂在登录门之后,见 server.js) + * + * 与 server.js 里那三个基础接口(上传 / 任务列表 / 单条复核)的分工: + * 那边只管「把文件变成待复核的行」,这边管「行怎么认出来的、怎么进产品库」。 + * + * 权限口径:仍然沿用 `cleanup.view` —— 系统里目前没有 `cleanup.edit` 这个点, + * 复核与入库都属于「合同清洗」这一个能力块。等以后要拆细粒度再补点。 + */ + +const express = require('express'); +const path = require('path'); + +const db = require('../db/db'); +const auth = require('../lib/auth'); +const catalog = require('../lib/skuCatalog'); +const parser = require('../lib/contractParser'); +const files = require('../lib/contractFiles'); +const aiProviders = require('../lib/aiProviders'); + +const router = express.Router(); + +const nowIso = () => new Date().toISOString(); +function badRequest(message) { + return Object.assign(new Error(message), { statusCode: 400 }); +} +function fail(res, error) { + const message = error?.message || '请求处理失败'; + const status = error?.statusCode || (/不存在|无效|格式|不能为空|请先|没有|已过期/.test(message) ? 400 : 500); + res.status(status).json({ error: message }); +} + +const getJob = id => db.prepare('SELECT * FROM contract_import_jobs WHERE id = ?').get(Number(id)); + +/* ============================== 解析规则 ============================== */ + +/** + * 当前生效的规则(内置默认 + 用户覆盖)。 + * 写这个接口是为了让「通用性」可验证:用户能一眼看到系统到底认哪些词, + * 再照着 JSON 覆盖文件去加自己行业的说法。 + */ +router.get('/parse-rules', (req, res) => { + try { + const rules = parser.loadRules(); + res.json({ + options: rules.options, + fields: Object.entries(rules.fields).map(([key, field]) => ({ + key, + label: field.label, + type: field.type, + keys: field.keys, + exclude: field.exclude, + regex: field.regex || [] + })), + override_source: rules.override_source, + override_file: parser.overrideFile, + hint: '覆盖文件(可选):在数据目录放 contract-rules.json,或在设置表 settings["contract.rules"] 写同样的 JSON。' + }); + } catch (error) { fail(res, error); } +}); + +/** 保存覆盖(存 settings;文件形式的覆盖优先级更高,见 contractParser.readOverride) */ +router.put('/parse-rules', auth.requirePermission('cleanup.view'), (req, res) => { + try { + const body = req.body; + if (!body || typeof body !== 'object' || Array.isArray(body)) throw badRequest('规则必须是 JSON 对象'); + const allowed = ['fields', 'options', 'disable']; + if (!Object.keys(body).some(key => allowed.includes(key))) { + throw badRequest(`规则里至少要有一项:${allowed.join(' / ')}`); + } + if (body.fields && (typeof body.fields !== 'object' || Array.isArray(body.fields))) throw badRequest('fields 必须是对象'); + db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)') + .run('contract.rules', JSON.stringify(body)); + auth.audit({ username: req.user?.username, action: 'contract_rules_update', detail: '更新合同解析规则覆盖', req }); + res.json({ ok: true, rules: parser.loadRules(), message: '规则已保存,下次解析立即生效' }); + } catch (error) { fail(res, error); } +}); + +/* ============================== AI 解析 ============================== */ + +/** 从模型返回里把 JSON 数组抠出来:容忍 ```json 代码块、前后废话 */ +function extractJsonArray(text) { + let body = String(text || '').trim(); + body = body.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/, '').trim(); + const start = body.indexOf('['); + const end = body.lastIndexOf(']'); + if (start < 0 || end <= start) return null; + try { + const parsed = JSON.parse(body.slice(start, end + 1)); + return Array.isArray(parsed) ? parsed : null; + } catch { + return null; + } +} + +const AI_FIELDS = ['sku', 'product_name', 'spec', 'purchase_price', 'currency', 'quantity', 'moq']; + +const AI_SYSTEM_PROMPT = [ + '你是外贸采购数据抽取引擎。用户会给你一张供应商合同/报价单里的表格(CSV 文本,可能由 Excel 转出,列的顺序与命名都不固定)。', + '请把每一行商品抽成 JSON。', + '', + '硬性要求:', + '1) 只输出一个 JSON 数组,不要任何解释、不要 markdown 代码块、不要多余字段。', + '2) purchase_price 必须是数值(采购价/供货价/出厂价/单价/EXW/FOB 都算);', + ' 金额合计、总价、小计、税额、运费、建议零售价一律**不要**当采购价。', + '3) quantity 是数量,moq 是最小起订量,两者不要混。', + '4) currency 用 ISO 三字码(CNY/USD/EUR…),没有线索就 null。', + `5) 每个对象只允许这些键:${AI_FIELDS.join(', ')}。`, + '6) 抽不到的字段填 null,**不要编造**。', + '7) 忽略小计/合计/备注/页脚/公司抬头这类非商品行。' +].join('\n'); + +/** + * 用模型重解析一个任务。 + * + * 为什么用「原件」而不是「已解析的行」:规则解析失败时(正是需要 AI 的场景), + * 队列里可能只有一行占位记录,把那些行喂给模型等于让它猜一份残缺的表。 + * 所以优先读 7 天内还留着的原件;原件过期了才退回用队列里的原文。 + */ +router.post('/import-jobs/:id/ai-parse', auth.requirePermission('cleanup.view'), async (req, res) => { + try { + const job = getJob(req.params.id); + if (!job) throw badRequest('导入任务不存在'); + + const resolved = aiProviders.resolveAssignment('contract_parse'); + if (!resolved) { + throw badRequest('还没有给「合同智能解析」分配模型:请到 设置 → 智能配置 里,把某个接入分配给这个用途'); + } + + // 拼素材:优先原件,其次队列里的 raw_text + let sourceText = ''; + let sourceLabel = ''; + let sourceNames = []; + try { sourceNames = JSON.parse(job.source_files || '[]'); } catch { sourceNames = []; } + for (const name of sourceNames) { + const original = files.readOriginal(job.job_no, name); + if (!original) continue; + const described = parser.describeForAi(original.buffer, original.name, { maxRows: 120, maxChars: 9000 }); + if (!described.text) continue; + sourceText += `\n### 文件:${original.name}\n${described.text}\n`; + sourceLabel = original.name; + if (sourceText.length > 16000) break; + } + if (!sourceText) { + const rows = db.prepare( + 'SELECT row_no, raw_text FROM contract_import_items WHERE job_id = ? AND raw_text IS NOT NULL ORDER BY row_no LIMIT 120' + ).all(job.id); + if (rows.length >= 2) { + sourceText = rows.map(row => row.raw_text).join('\n'); + sourceLabel = '队列内已解析的原文'; + } + } + if (!sourceText.trim()) { + throw badRequest('拿不到可解析的内容:原件已过保留期(7 天)被清理,请把文件重新上传一次'); + } + + const answer = await aiProviders.chatByPurpose('contract_parse', [ + { role: 'system', content: AI_SYSTEM_PROMPT }, + { role: 'user', content: `请抽取下面表格里的商品行:\n\n${sourceText}` } + ], { maxTokens: 4000, temperature: 0 }); + + if (!answer) throw badRequest('没有可用的模型接入'); + if (!answer.ok) throw Object.assign(new Error(`模型调用失败:${answer.error}`), { statusCode: 502 }); + + const list = extractJsonArray(answer.text); + if (!list || !list.length) throw badRequest('模型没有返回可用的 JSON 数组,建议稍后重试或改用手工补录'); + + const items = list.map((row, index) => ({ + sku: row?.sku ? String(row.sku).trim().slice(0, 120) : null, + product_name: row?.product_name ? String(row.product_name).trim().slice(0, 200) : null, + spec: row?.spec ? String(row.spec).trim().slice(0, 200) : null, + purchase_price: parser.toNumber(row?.purchase_price), + currency: row?.currency ? String(row.currency).trim().toUpperCase().slice(0, 8) : null, + quantity: parser.toNumber(row?.quantity), + moq: parser.toNumber(row?.moq), + row_no: index + 1 + })).filter(row => AI_FIELDS.some(key => row[key] !== null)); + + if (!items.length) throw badRequest('模型返回的内容里没有可用的字段,建议改用手工补录'); + + const now = nowIso(); + let replaced = 0; + db.transaction(() => { + /* 只清掉「上一次机器解析出来的、还没被人处理过」的行。 + 已经复核过/已入库的行必须留着 —— 用户可能在 AI 之前已经手工修过几条。 */ + const stale = db.prepare( + `SELECT COUNT(*) AS count FROM contract_import_items + WHERE job_id = ? AND status = 'pending_review' AND committed_at IS NULL + AND COALESCE(parse_source, 'rules') = 'rules'` + ).get(job.id).count; + db.prepare( + `DELETE FROM contract_import_items + WHERE job_id = ? AND status = 'pending_review' AND committed_at IS NULL + AND COALESCE(parse_source, 'rules') = 'rules'` + ).run(job.id); + replaced = stale; + + const ins = db.prepare( + `INSERT INTO contract_import_items (job_id, filename, row_no, raw_text, sku, product_name, spec, + purchase_price, currency, quantity, moq, status, review_note, + parse_source, created_at, updated_at) + VALUES (@job_id, @filename, @row_no, @raw_text, @sku, @product_name, @spec, + @purchase_price, @currency, @quantity, @moq, 'pending_review', @review_note, + 'ai', @now, @now)` + ); + for (const item of items) { + ins.run({ + job_id: job.id, + filename: sourceLabel || (sourceNames[0] || 'AI 解析'), + row_no: item.row_no, + raw_text: JSON.stringify(item).slice(0, 500), + sku: item.sku, + product_name: item.product_name, + spec: item.spec, + purchase_price: item.purchase_price, + currency: item.currency, + quantity: item.quantity, + moq: item.moq, + review_note: 'AI 解析结果,提交前请复核', + now + }); + } + + db.prepare(`UPDATE contract_import_jobs SET status = 'pending_review', parse_source = 'ai', + notes = ?, updated_at = ? WHERE id = ?`) + .run(`AI 解析:${answer.provider} / ${answer.model} · 抽出 ${items.length} 行`, now, job.id); + })(); + + auth.audit({ + username: req.user?.username, + action: 'contract_ai_parse', + detail: `任务 ${job.job_no} 用 AI 解析出 ${items.length} 行(替换未复核的规则行 ${replaced} 条)`, + req + }); + + res.json({ + ok: true, + job: getJob(job.id), + extracted: items.length, + replaced, + provider: answer.provider, + model: answer.model, + message: `AI 解析出 ${items.length} 行,已进入待复核队列` + }); + } catch (error) { fail(res, error); } +}); + +/* ============================== 入库(批量) ============================== */ + +/** + * 把勾选的明细写进产品库。 + * + * 口径(Wayne 2026-09-16 定): + * - 已存在的货号 → 更新采购价(并写一条价格历史,来源记合同文件名) + * - 库里没有的货号 → 新建(沿用产品库原有的「撞号落 -COPY2」规则) + * - 入库即视为复核通过,同时补上 reviewed_by / committed_at —— 这两项以前是空的 + * + * 逐条一个事务:一次勾几百条时不能让写锁一直握着(同上传那里的取舍)。 + */ +router.post('/import-items/commit', auth.requirePermission('cleanup.view'), (req, res) => { + try { + const rawIds = Array.isArray(req.body?.ids) ? req.body.ids : []; + const ids = [...new Set(rawIds.map(Number).filter(Number.isInteger))].slice(0, 2000); + if (!ids.length) throw badRequest('请先勾选要入库的明细'); + + const actor = req.user?.username || null; + const orgId = req.user?.org_id ?? null; + const now = nowIso(); + const summary = { updated: 0, created: 0, skipped: [], jobs: new Set(), contracts: new Set() }; + + for (const id of ids) { + const item = db.prepare('SELECT * FROM contract_import_items WHERE id = ?').get(id); + if (!item) { summary.skipped.push({ id, reason: '明细不存在' }); continue; } + if (item.committed_at) { + summary.skipped.push({ id, sku: item.product_sku, reason: '之前已经入库过了' }); + continue; + } + const skuText = String(item.sku || '').trim(); + if (!skuText) { summary.skipped.push({ id, reason: '没有货号,无法入库' }); continue; } + const price = parser.toNumber(item.purchase_price); + const moq = parser.toNumber(item.moq); + const key = catalog.normalizeSku(skuText); + const existing = db.prepare('SELECT * FROM products WHERE sku = ?').get(key); + + try { + let productSku = key; + if (existing) { + if (price === null) { summary.skipped.push({ id, sku: existing.display_sku || key, reason: '没有采购价,未更新' }); continue; } + db.transaction(() => { + /* 只补空字段,不覆盖已有的人工命名 —— 导入是来「补价」的,不是来改名的 */ + db.prepare( + `UPDATE products SET + price = @price, + moq = COALESCE(@moq, moq), + name = COALESCE(NULLIF(name, ''), @name), + spec = COALESCE(NULLIF(spec, ''), @spec), + source_contract = @source_contract, + parse_method = @parse_method, + confidence = 'high', + last_updated = @now, updated_at = @now + WHERE sku = @sku` + ).run({ + sku: key, + price, + moq, + name: item.product_name?.trim() || null, + spec: item.spec?.trim() || null, + source_contract: item.filename, + parse_method: item.parse_source === 'ai' ? 'ai' : 'rules', + now + }); + db.prepare( + `INSERT INTO price_history (sku, old_price, new_price, old_moq, new_moq, currency, + supplier_id, source_contract, confidence, changed_at, changed_by) + VALUES (@sku, @old_price, @price, @old_moq, @moq, @currency, @supplier_id, @source, 'high', @now, @actor)` + ).run({ + sku: key, + old_price: existing.price, + price, + old_moq: existing.moq, + moq, + currency: existing.currency || 'CNY', + supplier_id: existing.supplier_id ?? null, + // 价格历史里只记合同文件名(不记路径、不记任务号) + source: item.filename, + now, + actor + }); + })(); + summary.updated += 1; + } else { + const created = catalog.insertItem( + { sku: skuText, name: item.product_name, spec: item.spec, price, moq }, + null, null, actor, orgId, + { + sourceContract: item.filename, + parseMethod: item.parse_source === 'ai' ? 'ai' : 'rules', + currency: item.currency || 'CNY' + } + ); + if (created?.error) { summary.skipped.push({ id, reason: created.error }); continue; } + productSku = created.sku; + summary.created += 1; + } + + db.prepare( + `UPDATE contract_import_items + SET status = 'approved', reviewed_by = ?, reviewed_at = ?, + updated_at = ?, committed_at = ?, product_sku = ? + WHERE id = ?` + ).run(req.user?.id ?? null, now, now, now, productSku, id); + + summary.jobs.add(item.job_id); + summary.contracts.add(item.filename); + } catch (error) { + summary.skipped.push({ id, reason: error.message }); + } + } + + // 任务的「待复核」可能清空了,状态跟着走(和单条复核同一套派生规则) + for (const jobId of summary.jobs) { + const remaining = db.prepare( + `SELECT COUNT(*) AS count FROM contract_import_items WHERE job_id = ? AND status = 'pending_review'` + ).get(jobId).count; + db.prepare('UPDATE contract_import_jobs SET status = ?, updated_at = ?, completed_at = ? WHERE id = ?') + .run(remaining ? 'pending_review' : 'reviewed', now, remaining ? null : now, jobId); + } + + auth.audit({ + username: actor, + action: 'contract_commit', + detail: `合同入库:更新 ${summary.updated} 条、新建 ${summary.created} 条、跳过 ${summary.skipped.length} 条` + + (summary.contracts.size ? `(来源:${[...summary.contracts].join('、')})` : ''), + req + }); + + res.json({ + ok: true, + updated: summary.updated, + created: summary.created, + skipped: summary.skipped, + message: `入库完成:更新 ${summary.updated} 条、新建 ${summary.created} 条` + + (summary.skipped.length ? `、跳过 ${summary.skipped.length} 条` : '') + }); + } catch (error) { fail(res, error); } +}); + +/* ============================== 原件(7 天) ============================== */ + +router.get('/import-jobs/:id/files', (req, res) => { + try { + const job = getJob(req.params.id); + if (!job) throw badRequest('导入任务不存在'); + const list = files.listOriginals(job.job_no); + const created = Date.parse(job.created_at); + const expires = Number.isFinite(created) + ? new Date(created + files.RETENTION_DAYS * 24 * 60 * 60 * 1000).toISOString() + : null; + res.json({ + files: list, + retention_days: files.RETENTION_DAYS, + expires_at: expires, + expired: list.length === 0 && Number.isFinite(created) && Date.now() > created + files.RETENTION_DAYS * 24 * 60 * 60 * 1000 + }); + } catch (error) { fail(res, error); } +}); + +/** 下载一份原件。过期清理后返回 410,让界面能说清「原件已清理」而不是报一个坏链接。 */ +router.get('/import-jobs/:id/file', (req, res) => { + try { + const job = getJob(req.params.id); + if (!job) throw badRequest('导入任务不存在'); + const name = String(req.query.name || '').trim(); + if (!name) throw badRequest('缺少文件名参数 name'); + const file = files.readOriginal(job.job_no, name); + if (!file) { + return res.status(410).json({ + error: `原件已过保留期(${files.RETENTION_DAYS} 天)被自动清理,解析结果不受影响` + }); + } + res.setHeader('Content-Type', 'application/octet-stream'); + res.setHeader( + 'Content-Disposition', + `attachment; filename="contract"; filename*=UTF-8''${encodeURIComponent(path.basename(file.name))}` + ); + res.end(file.buffer); + } catch (error) { fail(res, error); } +}); + +module.exports = router; diff --git a/backend/routes/logs.js b/backend/routes/logs.js new file mode 100644 index 0000000..30853e0 --- /dev/null +++ b/backend/routes/logs.js @@ -0,0 +1,210 @@ +/** + * 系统日志(审计轨迹)—— 读取 audit_log 并提供筛选、统计与导出。 + * + * 为什么要有这一页:Wayne 2026-09-15 参照 GoodJob 的系统日志页提出 —— 登录、改密、 + * 用户/权限组/组织变更、客户归属流转、商机状态、AI 接入变更都会写 audit_log, + * 但这些记录此前只能在库里看见,界面上没有任何入口。 + * + * ⚠️ 挂载点刻意**不放在 /api/system/ 下** —— 那个前缀在免登录白名单里 + * (`/^\/api\/(health|auth\/|system\/)/`),放在那儿等于把全公司的操作日志公开。 + * + * 权限:`audit.view`,按 Wayne 的口径**只授给系统管理员组**(见 lib/permissions.js)。 + * 界面入口同理:没这个点的人,设置里根本看不到「系统日志」这一项。 + */ + +const express = require('express'); +const db = require('../db/db'); +const auth = require('../lib/auth'); + +const router = express.Router(); + +/* --------------------------------------------------------------------------- + * 分类:按 action 前缀归组。新增 action 时如果这里没覆盖,会落到「系统」, + * 不会丢记录 —— 宁可直接显示,也不要因为漏配而看不见。 + * ------------------------------------------------------------------------- */ +const CATEGORIES = [ + { key: 'auth', label: '认证', match: /^(login|logout|password_|profile_)/ }, + { key: 'user', label: '用户', match: /^user_/ }, + { key: 'permission', label: '权限', match: /^permission_group_/ }, + { key: 'org', label: '组织', match: /^(organization_|branding_)/ }, + { key: 'customer', label: '客户', match: /^(customer_|pool_|prospect_)/ }, + { key: 'contract', label: '合同', match: /^contract_/ }, + { key: 'ai', label: '智能', match: /^ai_/ } +] + +const CATEGORY_LABELS = { system: '系统', ...Object.fromEntries(CATEGORIES.map(c => [c.key, c.label])) } + +/* 动作的中文名。查不到就显示原始 action —— 便于新动作上线时立刻可读,不用等改代码。 */ +const ACTION_LABELS = { + login: '登录成功', + login_failed: '登录失败', + login_blocked: '登录被拦截', + logout: '退出登录', + profile_update: '更新个人资料', + password_change: '修改密码', + password_change_failed: '修改密码失败', + user_create: '新建用户', + user_update: '更新用户', + permission_group_create: '新建权限组', + permission_group_update: '更新权限组', + permission_group_delete: '删除权限组', + organization_create: '新建组织', + organization_update: '更新组织信息', + branding_update: '更新系统标识', + customer_create: '新建客户', + customer_to_pool: '客户丢入公海', + customer_claim: '公海领取客户', + prospect_deal_status: '商机状态变更', + ai_provider_create: '新增模型接入', + ai_provider_update: '更新模型接入', + ai_provider_delete: '移除模型接入', + ai_provider_test: '测试模型连通性', + ai_assignment_update: '调整模型用途分配', + contract_import: '导入合同', + contract_commit: '合同入库', + contract_ai_parse: '合同 AI 解析', + contract_rules_update: '更新合同解析规则' +} + +function categoryOf(action = '') { + const hit = CATEGORIES.find(c => c.match.test(action)) + return hit ? hit.key : 'system' +} + +/* 等级:成功 / 信息 / 警告 / 错误。登录失败算「警告」——它通常是输错密码, + 而不是系统故障,混进错误里会把真正的故障淹掉。 */ +const WRITE_ACTION = /_(create|update|delete|change|claim)$/ + +function levelOf(row) { + if (row.ok) { + if (WRITE_ACTION.test(row.action) || row.action === 'customer_to_pool') return 'success' + return 'info' + } + return /^(login_failed|login_blocked)$/.test(row.action) ? 'warning' : 'error' +} + +const LEVEL_LABELS = { info: '信息', success: '成功', warning: '警告', error: '错误' } + +/* --------------------------------------------------------------------------- + * 过滤条件只在这里拼一次:列表、计数、统计、导出共用,避免四处走散。 + * 全部走占位参数,不拼字符串。 + * ------------------------------------------------------------------------- */ +function buildFilter(query) { + const where = [] + const params = [] + + const level = String(query.level || '').trim() + const category = String(query.category || '').trim() + const keyword = String(query.q || '').trim() + + if (keyword) { + where.push('(username LIKE ? OR action LIKE ? OR detail LIKE ? OR ip LIKE ?)') + const like = `%${keyword}%` + params.push(like, like, like, like) + } + /* 分类与等级是「从 action / ok 算出来的」,SQL 里没有这两列, + 所以先选出候选行再在内存里筛 —— 日志量级(万行内)完全够用。 */ + const rows = db.prepare( + `SELECT id, at, username, action, detail, ip, user_agent, ok FROM audit_log + ${where.length ? 'WHERE ' + where.join(' AND ') : ''} + ORDER BY id DESC LIMIT 20000` + ).all(...params) + + const decorated = rows.map(row => ({ + ...row, + ok: row.ok ? 1 : 0, + category: categoryOf(row.action), + level: levelOf(row), + action_label: ACTION_LABELS[row.action] || row.action + })) + + return decorated.filter(row => + (!level || row.level === level) && (!category || row.category === category)) +} + +function dayStartIso() { + const d = new Date() + d.setHours(0, 0, 0, 0) + return d.toISOString() +} + +function statsOf(rows) { + /* 统计永远基于「当前筛选结果」,这样切了分类/等级之后四个数字才对得上列表。 */ + const today = dayStartIso() + return { + total: rows.length, + today: rows.filter(r => r.at >= today).length, + warning: rows.filter(r => r.level === 'warning').length, + error: rows.filter(r => r.level === 'error').length + } +} + +function csvCell(value) { + const text = value === null || value === undefined ? '' : String(value) + return `"${text.replace(/"/g, '""')}"` +} + +/** GET /api/logs —— 分页列表(带统计与可选分类) */ +router.get('/logs', auth.requirePermission('audit.view'), (req, res) => { + try { + const limit = Math.min(Math.max(Number(req.query.limit) || 50, 1), 200) + const offset = Math.max(Number(req.query.offset) || 0, 0) + const all = buildFilter(req.query) + + const categoryCounts = {} + for (const row of all) categoryCounts[row.category] = (categoryCounts[row.category] || 0) + 1 + + res.json({ + items: all.slice(offset, offset + limit), + total: all.length, + limit, + offset, + stats: statsOf(all), + categories: Object.entries(CATEGORY_LABELS) + .map(([key, label]) => ({ key, label, count: categoryCounts[key] || 0 })) + .filter(item => item.count > 0), + levels: Object.entries(LEVEL_LABELS).map(([key, label]) => ({ + key, label, count: all.filter(r => r.level === key).length + })) + }) + } catch (error) { + res.status(500).json({ error: '读取日志失败:' + error.message }) + } +}); + +/** GET /api/logs/export —— 导出当前筛选结果为 CSV(带 BOM,Excel 直接打开不乱码) */ +router.get('/logs/export', auth.requirePermission('audit.view'), (req, res) => { + try { + const rows = buildFilter(req.query).slice(0, 5000) + const header = ['时间', '等级', '分类', '操作人', '动作', '说明', 'IP', '结果'] + const lines = [header.map(csvCell).join(',')] + for (const row of rows) { + lines.push([ + row.at, + LEVEL_LABELS[row.level] || row.level, + CATEGORY_LABELS[row.category] || row.category, + row.username || '—', + row.action_label, + row.detail || '', + row.ip || '', + row.ok ? '成功' : '失败' + ].map(csvCell).join(',')) + } + + auth.audit({ + username: req.user.username, + action: 'audit_export', + detail: `导出系统日志 ${rows.length} 条`, + req + }) + + const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-') + res.setHeader('Content-Type', 'text/csv; charset=utf-8') + res.setHeader('Content-Disposition', `attachment; filename="system-logs-${stamp}.csv"`) + res.send('\uFEFF' + lines.join('\r\n')) + } catch (error) { + res.status(500).json({ error: '导出日志失败:' + error.message }) + } +}); + +module.exports = router; diff --git a/backend/routes/prospects.js b/backend/routes/prospects.js new file mode 100644 index 0000000..6db6620 --- /dev/null +++ b/backend/routes/prospects.js @@ -0,0 +1,113 @@ +/** + * 新客开发的「开发结果管理」。 + * 挂载点:/api/prospects + * + * 口径:新客开发页只做两件事 —— ① 用线索池找到新客户;② 管理开发结果。 + * 开发结果就落在 customers.deal_status 上:pending 未成交 / won 已成交 / lost 已流失。 + * 这样「我的客户」和「新客开发」看到的是同一批客户,不会出现两套数据打架。 + * + * 归属口径(Wayne 2026-09-14):客户「谁建的谁看得见」,所以本页也按归属过滤, + * 否则业务员在「新客开发」里能直接看到并标记同事的客户。 + */ +const express = require('express'); +const db = require('../db/db'); +const auth = require('../lib/auth'); +const access = require('../lib/customerAccess'); + +const router = express.Router(); +const DEAL_STATUSES = ['pending', 'won', 'lost']; + +function badRequest(message) { + const error = new Error(message); + error.statusCode = 400; + return error; +} +function fail(res, error) { + const message = error?.message || '请求处理失败'; + const status = error?.statusCode || (/不存在|无效|格式|不能为空/.test(message) ? 400 : 500); + res.status(status).json({ error: message }); +} + +function countBy(status, user) { + const { conditions, params } = access.scopeFilter(user, 'all'); + const where = ["COALESCE(c.deal_status, 'pending') = ?", ...conditions]; + return db.prepare(`SELECT COUNT(*) AS c FROM customers c WHERE ${where.join(' AND ')}`) + .get(status, ...params).c; +} + +/** GET /api/prospects?deal_status=won|lost|pending&q= */ +router.get('/', auth.requirePermission('prospect.view'), (req, res) => { + try { + const status = String(req.query.deal_status || '').trim(); + const keyword = String(req.query.q || '').trim(); + /* 归属过滤与本页自己的筛选条件合并 —— 归属条件务必放进来,它是硬边界 */ + const scope = access.scopeFilter(req.user, 'all'); + const conditions = [...scope.conditions]; + const params = [...scope.params]; + + if (DEAL_STATUSES.includes(status)) { + conditions.push("COALESCE(c.deal_status, 'pending') = ?"); + params.push(status); + } + if (keyword) { + conditions.push('(c.company LIKE ? OR c.company_en LIKE ? OR c.country LIKE ? OR c.email LIKE ?)'); + params.push(`%${keyword}%`, `%${keyword}%`, `%${keyword}%`, `%${keyword}%`); + } + + const items = db.prepare(` + SELECT c.*, + u.display_name AS owner_name, + (SELECT COUNT(*) FROM contacts ct WHERE ct.customer_id = c.id) AS contact_count, + (SELECT COUNT(*) FROM customer_quotations q WHERE q.customer_id = c.id) AS quote_count, + (SELECT MAX(f.created_at) FROM follow_up_records f WHERE f.customer_id = c.id) AS last_follow_at + FROM customers c + LEFT JOIN users u ON c.owner_id = u.id + ${conditions.length ? 'WHERE ' + conditions.join(' AND ') : ''} + ORDER BY COALESCE(c.updated_at, c.created_at) DESC + LIMIT 300 + `).all(...params); + + res.json({ + items, + stats: { + pending: countBy('pending', req.user), + won: countBy('won', req.user), + lost: countBy('lost', req.user) + } + }); + } catch (error) { + fail(res, error); + } +}); + +/** PATCH /api/prospects/:id —— 标记成交状态 */ +router.patch('/:id', auth.requirePermission('prospect.view'), (req, res) => { + try { + const id = Number(req.params.id); + const customer = db.prepare('SELECT * FROM customers WHERE id = ?').get(id); + if (!customer) throw badRequest('客户不存在'); + /* 标记成交 = 改客户数据,别人的客户不能改 */ + if (!access.canEdit(req.user, customer)) { + const error = new Error('这条客户不在你名下,请先到客户公海领取'); + error.statusCode = 403; + throw error; + } + + const status = String(req.body?.deal_status || '').trim(); + if (!DEAL_STATUSES.includes(status)) throw badRequest('成交状态取值无效'); + + db.prepare('UPDATE customers SET deal_status = ?, updated_at = ? WHERE id = ?') + .run(status, new Date().toISOString(), id); + auth.audit({ + username: req.user.username, + action: 'prospect_deal_status', + detail: `客户「${customer.company}」标记为 ${status}`, + req + }); + res.json({ ok: true, item: db.prepare('SELECT * FROM customers WHERE id = ?').get(id) }); + } catch (error) { + fail(res, error); + } +}); + +module.exports = router; diff --git a/backend/routes/sku.js b/backend/routes/sku.js new file mode 100644 index 0000000..d0f6fc5 --- /dev/null +++ b/backend/routes/sku.js @@ -0,0 +1,721 @@ +/** + * 产品目录(SKU)接口 —— 本地实现。 + * + * 这些路径以前是 4300 原样转发给 sku-manager(3300)的,现在直接落在本库执行。 + * **路径一个字没改**(/api/sku/… 与 /product-images/…),所以前端零改动。 + * + * 两处刻意偏离原实现,都是因为 happy 这边多了一张 product_packaging 表: + * · 删除货号 / 批量删产品时,连带清掉它的包装参数 —— product_packaging.sku 有外键 + * 指向 products(sku),不清就删不动;产品都没了,包装参数留着也没意义。 + * · 其余照搬 sku-manager 的口径,包括「调价历史一律保留备查」「撞号落副本不报错」。 + */ +const express = require('express'); +const multer = require('multer'); +const path = require('path'); +const db = require('../db/db'); +const catalog = require('../lib/skuCatalog'); + +const { + groupKey, normalizeSku, getItemAttrs, getGroupAttrs, replaceAttrs, + cleanupGroupIfEmpty, insertItem, getContractFile +} = catalog; + +/* ------------------------------ 上传(图片) ------------------------------ */ + +const ALLOWED_IMAGE_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif', '.bmp']); + +/* 直接进内存 → 立刻写进库里,磁盘上不留明文副本(合约与产品图都属于机密数据) */ +const imageUpload = multer({ + storage: multer.memoryStorage(), + defParamCharset: 'utf8', + fileFilter: (req, file, cb) => { + if (!ALLOWED_IMAGE_EXT.has(path.extname(file.originalname).toLowerCase())) return cb(null, false); + cb(null, true); + }, + limits: { files: 20, fileSize: 15 * 1024 * 1024 } +}); + +/* -------------------------------- 公共辅助 -------------------------------- */ + +/** 当前操作者。产品库要留「谁传的、谁改的价」,登录用户就是唯一来源。 */ +const actorOf = (req) => req.user?.display_name || req.user?.username || null; +const actorOrgOf = (req) => req.user?.org_id ?? null; + +const fail = (res, error, statusCode) => + res.status(statusCode || error?.statusCode || 500).json({ error: error?.message || '请求处理失败' }); + +const api = express.Router(); +const images = express.Router(); + +/* =============================== 查询与检索 =============================== */ + +/** + * 产品级搜索:一行一个产品,聚合价格区间/货号数;命中的货号单独列出。 + * + * 用 LIKE '%kw%' 全表扫 —— 前导通配符**用不上 B-tree 索引**,加索引也没用。 + * 当前规模(516 组 / 1574 货号)端到端 P50 才 8ms 出头,远不到要优化的地步。 + * 真要等到十万级货号再考虑 FTS5 虚拟表,而不是加索引。 + */ +api.get('/search', (req, res) => { + const q = (req.query.q || '').trim(); + const supplierId = req.query.supplier_id; + const onlyLow = req.query.only_low === '1'; + + const where = []; + const params = {}; + + if (q) { + where.push(`g.id IN ( + SELECT DISTINCT g2.id FROM product_groups g2 + LEFT JOIN products p2 ON p2.group_id = g2.id + LEFT JOIN item_attributes a2 ON (a2.group_id = g2.id OR a2.sku = p2.sku) + WHERE g2.name LIKE @q OR g2.brand LIKE @q OR g2.category LIKE @q OR g2.description LIKE @q + OR p2.sku LIKE @q OR p2.display_sku LIKE @q OR p2.name LIKE @q + OR p2.spec LIKE @q OR p2.description LIKE @q + OR a2.name LIKE @q OR a2.value LIKE @q + )`); + params.q = `%${q}%`; + } + if (supplierId === 'none') { + where.push('g.supplier_id IS NULL'); + } else if (supplierId) { + where.push('g.supplier_id = @supplier_id'); + params.supplier_id = Number(supplierId); + } + if (onlyLow) { + where.push(`EXISTS (SELECT 1 FROM products pl WHERE pl.group_id = g.id AND pl.confidence = 'low')`); + } + + const groups = db.prepare( + `SELECT g.id, g.name, g.brand, g.category, g.supplier_id, + s.name AS supplier_name, s.short_name AS supplier_short, + (SELECT COUNT(*) FROM products p WHERE p.group_id = g.id) AS item_count, + (SELECT MIN(p.price) FROM products p WHERE p.group_id = g.id) AS price_min, + (SELECT MAX(p.price) FROM products p WHERE p.group_id = g.id) AS price_max, + (SELECT COUNT(*) FROM products p WHERE p.group_id = g.id AND p.confidence = 'low') AS low_count, + (SELECT MAX(p.last_updated) FROM products p WHERE p.group_id = g.id) AS last_updated, + (SELECT filename FROM product_images i WHERE i.group_id = g.id + ORDER BY i.is_primary DESC, i.id ASC LIMIT 1) AS thumb, + (SELECT COUNT(*) FROM product_images i WHERE i.group_id = g.id) AS image_count + FROM product_groups g + LEFT JOIN suppliers s ON s.id = g.supplier_id + ${where.length ? 'WHERE ' + where.join(' AND ') : ''} + ORDER BY last_updated DESC LIMIT 300` + ).all(params); + + // 搜索词直接命中了哪些货号 —— 前端高亮用 + if (q && groups.length) { + const ids = groups.map((g) => g.id); + const hits = db.prepare( + `SELECT p.group_id, p.sku, p.display_sku FROM products p + WHERE p.group_id IN (${ids.map(() => '?').join(',')}) + AND (p.sku LIKE ? OR p.display_sku LIKE ? OR p.spec LIKE ? OR p.description LIKE ? + OR p.sku IN (SELECT a.sku FROM item_attributes a WHERE a.sku IS NOT NULL AND (a.name LIKE ? OR a.value LIKE ?)))` + ).all(...ids, params.q, params.q, params.q, params.q, params.q, params.q); + const byGroup = {}; + for (const h of hits) (byGroup[h.group_id] = byGroup[h.group_id] || []).push({ sku: h.sku, display_sku: h.display_sku }); + for (const g of groups) g.matched_items = byGroup[g.id] || []; + } + + res.json(groups); +}); + +/** 产品详情:产品 + 全部货号(含各自参数)+ 产品级参数 + 图片。 */ +api.get('/group/:id', (req, res) => { + const id = Number(req.params.id); + const group = db.prepare( + `SELECT g.*, s.name AS supplier_name FROM product_groups g + LEFT JOIN suppliers s ON s.id = g.supplier_id WHERE g.id = ?` + ).get(id); + if (!group) return res.status(404).json({ error: 'not found' }); + + const items = db.prepare( + `SELECT p.*, c.terms AS contract_terms, + (SELECT pi.filename FROM product_images pi + WHERE pi.group_id = p.group_id ORDER BY pi.is_primary DESC, pi.id ASC LIMIT 1) AS thumb + FROM products p + LEFT JOIN contracts c ON c.id = p.contract_id + WHERE p.group_id = ? ORDER BY p.display_sku` + ).all(id); + for (const it of items) it.attributes = getItemAttrs.all(it.sku); + + res.json({ + group, + groupAttributes: getGroupAttrs.all(id), + items, + images: db.prepare('SELECT * FROM product_images WHERE group_id = ? ORDER BY is_primary DESC, id ASC').all(id) + }); +}); + +/** 编辑产品(名称/品牌/分类/供应商/说明/产品级参数)。 */ +api.put('/group/:id', (req, res) => { + const id = Number(req.params.id); + const g = db.prepare('SELECT * FROM product_groups WHERE id = ?').get(id); + if (!g) return res.status(404).json({ error: 'not found' }); + + const b = req.body || {}; + const name = b.name?.trim(); + if (!name) return res.status(400).json({ error: '产品名称不能为空' }); + + db.prepare( + `UPDATE product_groups SET name=@name, group_key=@group_key, brand=@brand, + category=@category, description=@description, supplier_id=@supplier_id, updated_at=@now WHERE id=@id` + ).run({ + id, + name, + group_key: groupKey(name), + brand: b.brand?.trim() || null, + category: b.category?.trim() || null, + description: b.description?.trim() || null, + supplier_id: b.supplier_id ? Number(b.supplier_id) : null, + now: new Date().toISOString() + }); + + if (Array.isArray(b.attributes)) replaceAttrs({ groupId: id }, b.attributes); + res.json({ ok: true }); +}); + +/* ================================ 货号读写 ================================ */ + +/** 新建产品(必须带第一个货号)。 */ +api.post('/groups', (req, res) => { + const b = req.body || {}; + const name = b.name?.trim(); + if (!name) return res.status(400).json({ error: '产品名称不能为空' }); + if (!b.item || !String(b.item.sku || '').trim()) return res.status(400).json({ error: '至少需要一个货号' }); + + const supplierId = b.supplier_id ? Number(b.supplier_id) : null; + const groupId = db.prepare( + `INSERT INTO product_groups (name, group_key, brand, category, description, supplier_id, created_at) + VALUES (@name, @group_key, @brand, @category, @description, @supplier_id, @created_at)` + ).run({ + name, + group_key: groupKey(name), + brand: b.brand?.trim() || null, + category: b.category?.trim() || null, + description: b.description?.trim() || null, + supplier_id: supplierId, + created_at: new Date().toISOString() + }).lastInsertRowid; + + const r = insertItem({ ...b.item, name }, groupId, supplierId, actorOf(req), actorOrgOf(req)); + if (r.error) { + cleanupGroupIfEmpty(groupId); + return res.status(400).json(r); + } + res.json({ + group_id: groupId, + sku: r.sku, + display_sku: r.displaySku, + duplicated_from: r.duplicatedFrom || null, + uploader: actorOf(req) + }); +}); + +/** 给已有产品加货号。 */ +api.post('/group/:id/items', (req, res) => { + const id = Number(req.params.id); + const g = db.prepare('SELECT * FROM product_groups WHERE id = ?').get(id); + if (!g) return res.status(404).json({ error: 'not found' }); + + const r = insertItem({ ...req.body, name: req.body?.name ?? g.name }, id, g.supplier_id, actorOf(req), actorOrgOf(req)); + if (r.error) return res.status(400).json(r); + res.json({ + sku: r.sku, + display_sku: r.displaySku, + duplicated_from: r.duplicatedFrom || null, + uploader: actorOf(req) + }); +}); + +/** 货号详情(编辑器用):含参数与调价历史。 */ +api.get('/product/:sku', (req, res) => { + const sku = req.params.sku.toUpperCase(); + const product = db.prepare( + `SELECT p.*, s.name AS supplier_name, c.terms AS contract_terms + FROM products p + LEFT JOIN suppliers s ON s.id = p.supplier_id + LEFT JOIN contracts c ON c.id = p.contract_id + WHERE p.sku = ?` + ).get(sku); + if (!product) return res.status(404).json({ error: 'not found' }); + + product.attributes = getItemAttrs.all(sku); + const history = db.prepare( + `SELECT h.*, s.name AS supplier_name FROM price_history h + LEFT JOIN suppliers s ON s.id = h.supplier_id + WHERE h.sku = ? ORDER BY h.changed_at DESC LIMIT 50` + ).all(sku); + res.json({ product, history }); +}); + +/** 手工修正货号 —— 表头千奇百怪,总有识别不准需要人工改的。 */ +api.put('/product/:sku', (req, res) => { + const sku = req.params.sku.toUpperCase(); + const existing = db.prepare('SELECT * FROM products WHERE sku = ?').get(sku); + if (!existing) return res.status(404).json({ error: 'not found' }); + + const b = req.body || {}; + const moq = b.moq === '' || b.moq === null || b.moq === undefined ? null : Number(b.moq); + if (moq !== null && (!Number.isFinite(moq) || moq < 0)) return res.status(400).json({ error: '起订量必须是非负数字' }); + + /* 价格只有在没有合同来源时才允许手改 —— 合同解析出来的价格必须保持可追溯, + 否则下次导入同一份合同又被覆盖,反而让人以为改丢了。 */ + const priceEditable = !existing.contract_id; + let price = existing.price; + if (priceEditable && b.price !== undefined) { + price = b.price === '' || b.price === null ? null : Number(b.price); + if (price !== null && (!Number.isFinite(price) || price < 0)) { + return res.status(400).json({ error: '采购价必须是非负数字' }); + } + } + + // 移动到其他产品 / 独立成新产品 + let targetGroup = existing.group_id; + const oldGroup = existing.group_id; + if (b.group_id === 'new') { + targetGroup = db.prepare( + `INSERT INTO product_groups (name, group_key, supplier_id, created_at) + VALUES (@name, @group_key, @supplier_id, @created_at)` + ).run({ + name: b.name?.trim() || existing.name || existing.display_sku || sku, + group_key: 'SKU:' + sku, + supplier_id: existing.supplier_id ?? null, + created_at: new Date().toISOString() + }).lastInsertRowid; + } else if (b.group_id !== undefined && b.group_id !== null && b.group_id !== '') { + const g = db.prepare('SELECT id FROM product_groups WHERE id = ?').get(Number(b.group_id)); + if (!g) return res.status(400).json({ error: '目标产品不存在' }); + targetGroup = g.id; + } + + const now = new Date().toISOString(); + db.transaction(() => { + db.prepare( + `UPDATE products SET + name = @name, spec = @spec, description = @description, + price = @price, moq = @moq, group_id = @group_id, + last_updated = @now, updated_at = @now + WHERE sku = @sku` + ).run({ + sku, + name: b.name?.trim() || null, + spec: b.spec?.trim() || null, + description: b.description?.trim() || null, + price, + moq, + group_id: targetGroup, + now + }); + + if (priceEditable && (price !== existing.price || moq !== existing.moq)) { + db.prepare( + `INSERT INTO price_history (sku, old_price, new_price, old_moq, new_moq, currency, + supplier_id, source_contract, confidence, changed_at, changed_by) + VALUES (@sku, @old_price, @new_price, @old_moq, @new_moq, 'CNY', + @supplier_id, '手工修改', 'high', @now, @changed_by)` + ).run({ + sku, + old_price: existing.price, + new_price: price, + old_moq: existing.moq, + new_moq: moq, + supplier_id: existing.supplier_id ?? null, + now, + changed_by: actorOf(req) + }); + } + })(); + + if (Array.isArray(b.attributes)) replaceAttrs({ sku }, b.attributes); + if (targetGroup !== oldGroup) cleanupGroupIfEmpty(oldGroup); + + res.json({ ok: true, priceEditable, group_id: targetGroup }); +}); + +/** 删除货号(调价历史保留备查;所在产品变空则一并清理)。 */ +api.delete('/product/:sku', (req, res) => { + const sku = req.params.sku.toUpperCase(); + const existing = db.prepare('SELECT * FROM products WHERE sku = ?').get(sku); + if (!existing) return res.status(404).json({ error: 'not found' }); + + db.transaction(() => { + db.prepare('DELETE FROM item_attributes WHERE sku = ?').run(sku); + db.prepare('DELETE FROM cart_items WHERE sku = ?').run(sku); + // happy 特有:包装参数挂在外键上,产品没了必须先摘掉,否则删不动 + db.prepare('DELETE FROM product_packaging WHERE sku = ?').run(sku); + db.prepare('DELETE FROM products WHERE sku = ?').run(sku); + })(); + cleanupGroupIfEmpty(existing.group_id); + res.json({ ok: true }); +}); + +/** 批量删除产品(整个 SPU 连同下挂货号)。 */ +api.post('/groups/delete', (req, res) => { + const ids = (Array.isArray(req.body?.ids) ? req.body.ids : []) + .map(Number) + .filter((n) => Number.isInteger(n) && n > 0); + if (!ids.length) return res.status(400).json({ error: '没有选中任何产品' }); + + const ph = ids.map(() => '?').join(','); + const imgIds = db.prepare(`SELECT id FROM product_images WHERE group_id IN (${ph})`).all(...ids).map((r) => r.id); + + let groups = 0; + let items = 0; + db.transaction(() => { + // 图片二进制跟着一起删,否则库里会积一堆没人引用的 BLOB,越攒越大 + catalog.deleteImagesFor(imgIds); + const skus = db.prepare(`SELECT sku FROM products WHERE group_id IN (${ph})`).all(...ids); + items = skus.length; + if (skus.length) { + const sph = skus.map(() => '?').join(','); + const args = skus.map((r) => r.sku); + db.prepare(`DELETE FROM item_attributes WHERE sku IN (${sph})`).run(...args); + db.prepare(`DELETE FROM cart_items WHERE sku IN (${sph})`).run(...args); + db.prepare(`DELETE FROM product_packaging WHERE sku IN (${sph})`).run(...args); + db.prepare(`DELETE FROM products WHERE sku IN (${sph})`).run(...args); + } + db.prepare(`DELETE FROM item_attributes WHERE group_id IN (${ph})`).run(...ids); + db.prepare(`DELETE FROM product_images WHERE group_id IN (${ph})`).run(...ids); + groups = db.prepare(`DELETE FROM product_groups WHERE id IN (${ph})`).run(...ids).changes; + })(); + + res.json({ ok: true, groups, items }); +}); + +/* ================================= 小推车 ================================= */ + +const CART_COLUMNS = ` + c.sku, c.qty, c.note, c.sort, c.added_at, + p.display_sku, p.spec, p.price, p.currency, p.moq, + p.description, p.confidence, p.last_updated, + g.id AS group_id, g.name AS product_name, g.brand, g.category, + s.name AS supplier_name, s.short_name AS supplier_short, + (SELECT filename FROM product_images i WHERE i.group_id = g.id + ORDER BY i.is_primary DESC, i.id ASC LIMIT 1) AS image`; + +const cartRows = () => db.prepare( + `SELECT ${CART_COLUMNS} + FROM cart_items c + JOIN products p ON p.sku = c.sku + LEFT JOIN product_groups g ON g.id = p.group_id + LEFT JOIN suppliers s ON s.id = p.supplier_id + ORDER BY c.sort, c.id` +).all(); + +api.get('/cart', (req, res) => res.json(cartRows())); + +/** + * 加入小推车。可以直接给货号,也可以给产品 id(该产品下所有货号一起进)。 + * 已在车里的不动 —— 重复点「加入」不该把用户排好的顺序打乱。 + */ +api.post('/cart', (req, res) => { + const skus = (Array.isArray(req.body?.skus) ? req.body.skus : []).map((s) => String(s).toUpperCase()); + const groupIds = (Array.isArray(req.body?.group_ids) ? req.body.group_ids : []) + .map(Number) + .filter((n) => Number.isInteger(n) && n > 0); + + if (groupIds.length) { + const ph = groupIds.map(() => '?').join(','); + for (const r of db.prepare(`SELECT sku FROM products WHERE group_id IN (${ph}) ORDER BY display_sku`).all(...groupIds)) { + skus.push(r.sku); + } + } + if (!skus.length) return res.status(400).json({ error: '没有可加入的货号' }); + + const now = new Date().toISOString(); + let added = 0; + db.transaction(() => { + let sort = db.prepare('SELECT COALESCE(MAX(sort), -1) AS m FROM cart_items').get().m; + const exists = db.prepare('SELECT 1 FROM cart_items WHERE sku = ?'); + const real = db.prepare('SELECT 1 FROM products WHERE sku = ?'); + const ins = db.prepare('INSERT INTO cart_items (sku, sort, added_at) VALUES (@sku, @sort, @added_at)'); + for (const sku of skus) { + if (exists.get(sku) || !real.get(sku)) continue; + ins.run({ sku, sort: ++sort, added_at: now }); + added += 1; + } + })(); + + res.json({ ok: true, added, rows: cartRows() }); +}); + +api.patch('/cart/:sku', (req, res) => { + const sku = req.params.sku.toUpperCase(); + if (!db.prepare('SELECT 1 FROM cart_items WHERE sku = ?').get(sku)) return res.status(404).json({ error: 'not found' }); + + const b = req.body || {}; + if ('qty' in b) { + // 空/0 表示「没定数量」,出报价单时按未填处理,不要落成 0 + const n = Number(b.qty); + db.prepare('UPDATE cart_items SET qty = ? WHERE sku = ?').run(Number.isFinite(n) && n > 0 ? Math.round(n) : null, sku); + } + if ('note' in b) { + db.prepare('UPDATE cart_items SET note = ? WHERE sku = ?').run(String(b.note || '').trim() || null, sku); + } + res.json({ ok: true, rows: cartRows() }); +}); + +api.delete('/cart/:sku', (req, res) => { + db.prepare('DELETE FROM cart_items WHERE sku = ?').run(req.params.sku.toUpperCase()); + res.json({ ok: true, rows: cartRows() }); +}); + +api.post('/cart/clear', (req, res) => { + db.prepare('DELETE FROM cart_items').run(); + res.json({ ok: true, rows: [] }); +}); + +/** 拖拽排序落库。只认车里已有的货号,顺序按传入数组重排。 */ +api.put('/cart/order', (req, res) => { + const skus = (Array.isArray(req.body?.skus) ? req.body.skus : []).map((s) => String(s).toUpperCase()); + db.transaction(() => { + const upd = db.prepare('UPDATE cart_items SET sort = ? WHERE sku = ?'); + skus.forEach((sku, i) => upd.run(i, sku)); + })(); + res.json({ ok: true, rows: cartRows() }); +}); + +/* =============================== 参数与图片 =============================== */ + +/** 参数名自动补全 —— 输过一次的参数名,下次直接选。 */ +api.get('/attr-names', (req, res) => { + const q = (req.query.q || '').trim(); + const rows = q + ? db.prepare('SELECT DISTINCT name FROM item_attributes WHERE name LIKE ? ORDER BY name LIMIT 30').all(`%${q}%`) + : db.prepare('SELECT DISTINCT name FROM item_attributes ORDER BY name LIMIT 30').all(); + res.json(rows.map((r) => r.name)); +}); + +/** + * 批量取货号缩略图。 + * + * 「凡是出现货号的地方最前面都要有缩略图」,那些位置(询盘明细、报价明细、合同待复核、 + * 选品器)手里只有货号、没有图片字段 —— 逐个调 /api/sku/product/:sku 既慢又拿不到图, + * 所以给一个一次问一批的入口。 + * + * 图片挂在**产品组**上(product_images.group_id),先按货号找到组再取该组主图。 + * 大小写不敏感(客户端传来的货号大小写不一定规范)。单次上限 500,防止被塞超大数组。 + */ +api.get('/thumbs', (req, res) => { + const skus = [...new Set(String(req.query.skus || '').split(',').map((s) => s.trim()).filter(Boolean))].slice(0, 500); + if (!skus.length) return res.json({ thumbs: {} }); + + const rows = db.prepare( + `SELECT p.sku, + (SELECT pi.filename FROM product_images pi + WHERE pi.group_id = p.group_id AND pi.group_id IS NOT NULL + ORDER BY pi.is_primary DESC, pi.id ASC LIMIT 1) AS thumb + FROM products p + WHERE p.sku IN (${skus.map(() => '?').join(',')}) COLLATE NOCASE` + ).all(...skus); + + const thumbs = {}; + for (const row of rows) if (row.thumb) thumbs[row.sku] = row.thumb; + res.json({ thumbs }); +}); + +api.post('/group/:id/images', imageUpload.array('images', 20), (req, res) => { + const id = Number(req.params.id); + if (!db.prepare('SELECT id FROM product_groups WHERE id = ?').get(id)) return res.status(404).json({ error: 'not found' }); + if (!req.files || !req.files.length) { + return res.status(400).json({ error: '没有收到图片(只支持 jpg/png/webp/gif/bmp)' }); + } + + const existing = db.prepare('SELECT COUNT(*) AS n FROM product_images WHERE group_id = ?').get(id).n; + const insert = db.prepare( + `INSERT INTO product_images (sku, group_id, filename, original_name, is_primary, created_at) + VALUES ('', @group_id, @filename, @original_name, @is_primary, @created_at)` + ); + + db.transaction(() => { + req.files.forEach((f, i) => { + const ext = path.extname(f.originalname).toLowerCase(); + const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`; + const info = insert.run({ + group_id: id, + filename, + original_name: f.originalname, + is_primary: existing === 0 && i === 0 ? 1 : 0, + created_at: new Date().toISOString() + }); + // 二进制直接进库;文件本来就在内存里,磁盘上不留明文副本 + catalog.storeImage(info.lastInsertRowid, f.buffer); + }); + })(); + + res.json({ + images: db.prepare('SELECT * FROM product_images WHERE group_id = ? ORDER BY is_primary DESC, id ASC').all(id) + }); +}); + +api.post('/images/:id/primary', (req, res) => { + const img = db.prepare('SELECT * FROM product_images WHERE id = ?').get(Number(req.params.id)); + if (!img) return res.status(404).json({ error: 'not found' }); + db.transaction(() => { + db.prepare('UPDATE product_images SET is_primary = 0 WHERE group_id = ?').run(img.group_id); + db.prepare('UPDATE product_images SET is_primary = 1 WHERE id = ?').run(img.id); + })(); + res.json({ ok: true }); +}); + +api.delete('/images/:id', (req, res) => { + const img = db.prepare('SELECT * FROM product_images WHERE id = ?').get(Number(req.params.id)); + if (!img) return res.status(404).json({ error: 'not found' }); + + db.prepare('DELETE FROM product_images WHERE id = ?').run(img.id); + // 删掉记录后把封面顺延给剩下的第一张 + if (img.is_primary) { + const next = db.prepare('SELECT id FROM product_images WHERE group_id = ? ORDER BY id ASC LIMIT 1').get(img.group_id); + if (next) db.prepare('UPDATE product_images SET is_primary = 1 WHERE id = ?').run(next.id); + } + catalog.deleteImage(img.id); + res.json({ ok: true }); +}); + +/** 下载合同原件(原件只存在库里,磁盘上没有)。 */ +api.get('/contracts/:id/file', (req, res) => { + const f = getContractFile(Number(req.params.id)); + if (!f) return res.status(404).json({ error: '库里没有这份合同的原件' }); + + res.setHeader('Content-Type', f.mime || 'application/octet-stream'); + res.setHeader( + 'Content-Disposition', + `attachment; filename="contract"; filename*=UTF-8''${encodeURIComponent(f.filename)}` + ); + res.end(f.bytes); +}); + +/* ================================== 统计 ================================== */ + +api.get('/stats', (req, res) => { + res.json(db.prepare( + `SELECT + (SELECT COUNT(*) FROM product_groups) AS total_groups, + (SELECT COUNT(*) FROM products) AS total_skus, + (SELECT COUNT(*) FROM products WHERE confidence = 'low') AS low_confidence, + (SELECT COUNT(*) FROM suppliers) AS total_suppliers, + (SELECT COUNT(*) FROM contracts) AS contracts_processed` + ).get()); +}); + +/* ================================= 供应商 ================================= */ + +api.get('/suppliers', (req, res) => { + res.json(db.prepare( + `SELECT s.*, + (SELECT COUNT(*) FROM product_groups g WHERE g.supplier_id = s.id) AS group_count, + (SELECT COUNT(*) FROM products p WHERE p.supplier_id = s.id) AS sku_count + FROM suppliers s ORDER BY s.name` + ).all()); +}); + +/** 没归属供应商的产品数 —— 手工建的货常常一时定不下供应商。 */ +api.get('/suppliers/unassigned/count', (req, res) => { + res.json(db.prepare('SELECT COUNT(*) AS count FROM product_groups WHERE supplier_id IS NULL').get()); +}); + +api.get('/suppliers/:id', (req, res) => { + const id = Number(req.params.id); + const supplier = db.prepare('SELECT * FROM suppliers WHERE id = ?').get(id); + if (!supplier) return res.status(404).json({ error: 'not found' }); + + res.json({ + supplier, + products: db.prepare( + `SELECT p.*, g.name AS group_name, + (SELECT filename FROM product_images i WHERE i.group_id = p.group_id + ORDER BY i.is_primary DESC, i.id ASC LIMIT 1) AS thumb + FROM products p LEFT JOIN product_groups g ON g.id = p.group_id + WHERE p.supplier_id = ? ORDER BY p.last_updated DESC LIMIT 500` + ).all(id), + contracts: db.prepare('SELECT * FROM contracts WHERE supplier_id = ? ORDER BY processed_at DESC LIMIT 50').all(id) + }); +}); + +const SUPPLIER_FIELDS = [ + 'name', 'short_name', 'contact_person', 'phone', + 'email', 'address', 'website', 'payment_terms', 'main_categories', 'notes' +]; + +const supplierPayload = (body) => { + const out = {}; + for (const f of SUPPLIER_FIELDS) out[f] = body?.[f] ?? null; + return out; +}; + +api.post('/suppliers', (req, res) => { + const data = supplierPayload(req.body); + if (!data.name) return res.status(400).json({ error: '供应商名称不能为空' }); + try { + const now = new Date().toISOString(); + const info = db.prepare( + `INSERT INTO suppliers (name, short_name, contact_person, phone, email, address, website, + payment_terms, main_categories, notes, created_at, updated_at) + VALUES (@name, @short_name, @contact_person, @phone, @email, @address, @website, + @payment_terms, @main_categories, @notes, @created_at, @updated_at)` + ).run({ ...data, created_at: now, updated_at: now }); + res.json({ id: info.lastInsertRowid }); + } catch (err) { + if (String(err.message).includes('UNIQUE')) return res.status(409).json({ error: '该供应商名称已存在' }); + fail(res, err); + } +}); + +api.put('/suppliers/:id', (req, res) => { + const data = supplierPayload(req.body); + if (!data.name) return res.status(400).json({ error: '供应商名称不能为空' }); + try { + db.prepare( + `UPDATE suppliers SET name=@name, short_name=@short_name, contact_person=@contact_person, + phone=@phone, email=@email, address=@address, website=@website, + payment_terms=@payment_terms, main_categories=@main_categories, notes=@notes, + updated_at=@updated_at WHERE id=@id` + ).run({ ...data, id: Number(req.params.id), updated_at: new Date().toISOString() }); + res.json({ ok: true }); + } catch (err) { + if (String(err.message).includes('UNIQUE')) return res.status(409).json({ error: '该供应商名称已存在' }); + fail(res, err); + } +}); + +api.delete('/suppliers/:id', (req, res) => { + const id = Number(req.params.id); + // 产品保留原价,只是变成「未指派供应商」,而不是跟着被删 + db.transaction(() => { + db.prepare('UPDATE products SET supplier_id = NULL WHERE supplier_id = ?').run(id); + db.prepare('UPDATE product_groups SET supplier_id = NULL WHERE supplier_id = ?').run(id); + db.prepare('DELETE FROM suppliers WHERE id = ?').run(id); + })(); + res.json({ ok: true }); +}); + +/* ============================== 产品图片文件 ============================== */ + +/** + * /product-images/<文件名>。 + * + * 图片存在库里(product_image_files),按文件名去查而不是拼磁盘路径 —— + * 顺带也就没有路径穿越这回事了。 + * + * 这里要求登录:产品图属于商业数据,原先挂在裸 static 上等于「猜到文件名就能拿图」。 + * 页面里的 是同源请求,会带上会话 cookie,所以登录后照常显示。 + */ +images.get('/:filename', (req, res) => { + if (!req.user) return res.status(401).end(); + + const name = req.params.filename; + const img = db.prepare('SELECT id FROM product_images WHERE filename = ?').get(name); + if (!img) return res.status(404).end(); + + const blob = catalog.getImage(img.id); + if (!blob) return res.status(404).end(); + + res.setHeader('Content-Type', catalog.mimeFor(name)); + // 文件名带时间戳+随机串,内容不会变,可以让浏览器长缓存。 + // private:机密数据,不许中间代理留副本。 + res.setHeader('Cache-Control', 'private, max-age=604800'); + res.end(blob.bytes); +}); + +module.exports = { api, images }; diff --git a/backend/routes/system.js b/backend/routes/system.js index 37c55ed..9365e13 100644 --- a/backend/routes/system.js +++ b/backend/routes/system.js @@ -4,6 +4,7 @@ */ const express = require('express'); const updater = require('../lib/updater'); +const branding = require('../lib/branding'); const router = express.Router(); @@ -45,4 +46,17 @@ router.get('/check-update', async (req, res) => { } }); +/** + * GET /api/system/branding + * 品牌与组织名。公开接口 —— 登录页在拿到会话之前就得知道系统叫什么。 + * 只回显示用的名字,不含任何业务数据。 + */ +router.get('/branding', (req, res) => { + try { + res.json(branding.getBranding()); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + module.exports = router; diff --git a/backend/server.js b/backend/server.js index 494d61e..c01e679 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,11 +1,16 @@ const express = require('express'); const path = require('path'); const cors = require('cors'); -const http = require('http'); +const multer = require('multer'); const db = require('./db/db'); const updater = require('./lib/updater'); const systemRoutes = require('./routes/system'); const fxRoutes = require('./routes/fx'); +const authRoutes = require('./routes/auth'); +const accessRoutes = require('./routes/access'); +const auth = require('./lib/auth'); +const customerAccess = require('./lib/customerAccess'); +const aiProviders = require('./lib/aiProviders'); const app = express(); const PORT = process.env.PORT || 4300; @@ -15,58 +20,112 @@ const HOST = '127.0.0.1'; app.use(cors()); // --------------------------------------------------------------------------- -// SKU 服务代理(过渡期) -// 把 /api/sku/* 原样转发给独立的 sku-manager 服务,让前端在正式合并(P1)完成前 -// 也能以同源路径访问 SKU 数据。必须放在 express.json() 之前,保持请求体的流式转发。 -// 合并完成后可整体移除,并改用本地路由。 +// 登录门(多用户阶段) +// attachUser 只读 cookie 与请求头、不消费请求体,所以可以放在最前面 —— +// 必须早于下面的 SKU 代理,否则 /api/sku/* 与 /product-images 会绕过鉴权。 +// 免登录的只有健康检查、登录相关和系统信息(前端要能在登录页拿到版本号)。 // --------------------------------------------------------------------------- -const SKU_ORIGIN = process.env.SKU_ORIGIN || 'http://127.0.0.1:3300'; +app.use(auth.attachUser); -/** - * 把请求原样转发到 sku-manager 服务。 - * @param {string} targetPath 转发到 SKU 服务的目标路径 - */ -function proxyToSku(targetPath) { - return (req, res) => { - let target; - try { - target = new URL(SKU_ORIGIN); - } catch { - return res.status(500).json({ error: `SKU_ORIGIN 配置无效:${SKU_ORIGIN}` }); - } +const PUBLIC_API_PATTERN = /^\/api\/(health|auth\/|system\/)/; +app.use('/api', (req, res, next) => { + if (PUBLIC_API_PATTERN.test(String(req.originalUrl || '').split('?')[0])) return next(); + if (!req.user) return res.status(401).json({ error: '请先登录' }); + next(); +}); - const proxyReq = http.request( - { - hostname: target.hostname, - port: target.port || 80, - path: targetPath, - method: req.method, - headers: { ...req.headers, host: target.host } - }, - (proxyRes) => { - res.writeHead(proxyRes.statusCode, proxyRes.headers); - proxyRes.pipe(res); - } - ); +// --------------------------------------------------------------------------- +// 产品目录(SKU 模块)已并入本服务 —— 原先是把 /api/sku/* 与 /product-images/* 转发给 +// 独立的 sku-manager(3300)。现在由 routes/sku.js 在本库直接执行,端口少一个, +// 路径一个字没改(前端零改动)。挂载点见下面「API 路由」段,必须在登录门之后。 +// --------------------------------------------------------------------------- - proxyReq.on('error', (err) => { - if (!res.headersSent) { - res.status(502).json({ error: `SKU 服务不可用(${SKU_ORIGIN}):${err.message}` }); - } - }); +// --------------------------------------------------------------------------- +// 产品目录解析。 +// 原先是「先请求 SKU 服务拿实时价、失败回退本地惰性镜像」—— 因为产品数据在另一个库。 +// 合并后数据就在本库同一张表,直接查即可,实现见 lib/skuCatalog.js 的 resolveProduct。 +// --------------------------------------------------------------------------- +const { resolveProduct } = require('./lib/skuCatalog'); +/* 合同清洗:认列规则引擎 + 原件磁盘留存(7 天自动清理) */ +const contractParser = require('./lib/contractParser'); +const contractFiles = require('./lib/contractFiles'); - req.pipe(proxyReq); - }; +/** SKU 相关接口的错误出口:找不到 SKU 给 404,其余交给通用错误处理。 */ +function skuError(res, error) { + if (/SKU 不存在|SKU 不能为空/.test(error.message)) return res.status(404).json({ error: error.message }); + return jsonError(res, error); } -// /api/sku/suppliers -> http://127.0.0.1:3300/api/suppliers -app.use('/api/sku', (req, res) => proxyToSku('/api' + req.url)(req, res)); -// /product-images/xxx.jpg -> http://127.0.0.1:3300/product-images/xxx.jpg -app.use('/product-images', (req, res) => proxyToSku('/product-images' + req.url)(req, res)); - app.use(express.json()); app.use(express.urlencoded({ extended: true })); +const upload = multer({ storage: multer.memoryStorage(), limits: { files: 20, fileSize: 10 * 1024 * 1024 } }); + +/** + * 修正上传文件名的编码。 + * + * multipart 里的文件名是裸字节,multer/busboy 为了兼容老客户端默认按 latin1 解码, + * 于是「腾达(扭矩扳手).xlsx」会变成「è¾¾ï¼ææï¼.xlsx」—— + * UTF-8 的「腾达」= E8 85 BE E8 BE BE,被逐字节当成了 latin1 字符。 + * + * 这里判断:只要出现 U+0080–U+00FF 区间的字符,就把它按 latin1 取回原始字节、 + * 再用 UTF-8 解一次。纯 ASCII 名不会命中;本来就正确的 UTF-8 中文名也不会命中 + * (汉字在 U+4E00 以上,不在该区间),所以不会误伤。 + * 回转后若出现替换字符 U+FFFD,说明原串并不是「UTF-8 被 latin1 解码」的产物,保持原样。 + */ +function decodeOriginalName(name) { + const raw = String(name || ''); + if (!raw || !/[\u0080-\u00ff]/.test(raw)) return raw; + const restored = Buffer.from(raw, 'latin1').toString('utf8'); + return restored.includes('\ufffd') ? raw : restored; +} + +/** 在任何用到文件名之前把 req.files / req.file 的 originalname 就地修正。 */ +function fixUploadFilenames(req, res, next) { + for (const file of req.files || []) file.originalname = decodeOriginalName(file.originalname); + if (req.file) req.file.originalname = decodeOriginalName(req.file.originalname); + next(); +} + +const nowIso = () => new Date().toISOString(); +const isDate = value => /^\d{4}-\d{2}-\d{2}$/.test(String(value || '')); +const numberOrNull = value => value === undefined || value === null || value === '' ? null : Number(value); +function validPositiveNumber(value, field) { + const number = Number(value); + if (!Number.isFinite(number) || number <= 0) throw new Error(`${field} 必须是大于 0 的数字`); + return number; +} +function getCustomer(id) { + const customerId = Number(id); + if (!Number.isInteger(customerId) || customerId <= 0) return null; + return db.prepare('SELECT * FROM customers WHERE id = ?').get(customerId); +} +/** + * 统一错误响应。 + * + * 状态码优先取调用方显式给的值(第三个参数,或 error.statusCode)—— + * 早先只靠消息里的中文关键词猜 400/500,一旦新写一句不含「必须/无效/不存在」的 + * 校验提示(例如「单价无法确定」),就会把业务校验错误错报成 500。 + * 关键词判断保留为兜底,别再往这个正则里堆词。 + */ +function jsonError(res, error, statusCode) { + const message = (error && error.message) || '请求处理失败'; + const status = statusCode || (error && error.statusCode) + || (/必须|不能为空|格式|不存在|无效|没有需要/.test(message) ? 400 : 500); + return res.status(status).json({ error: message }); +} + +/** 业务校验错误:明确回 400,不依赖 jsonError 的关键词兜底 */ +function badRequest(message) { + const error = new Error(message); + error.statusCode = 400; + return error; +} +/* 合同/报价单的认列逻辑已迁到 lib/contractParser.js(通用字符规则引擎)。 + 原来这里有个 parseImportText:写死 5 组别名、只支持 CSV/TSV/TXT、认不出就整列丢掉, + 且表头必须正好在第一行。现在换成「关键词/正则 + 权重打分 + 表头行探测 + 数值列体检」, + 并支持 xlsx/xls —— 换行业只需改配置,不用改代码。 */ + // 静态文件服务 app.use(express.static(path.join(__dirname, '../frontend/dist'))); @@ -82,22 +141,79 @@ app.get('/api/health', (req, res) => { // 系统信息与版本更新检查 app.use('/api/system', systemRoutes); +// 账号、个人资料与改密 +app.use('/api', authRoutes); + +// 组织与权限组 +app.use('/api', accessRoutes); + +// 系统日志(审计轨迹):列表 / 筛选 / 统计 / 导出 +// ⚠️ 挂在 /api/logs 而不是 /api/system/logs —— 后者在免登录白名单里,会把全公司的操作记录公开。 +app.use('/api', require('./routes/logs')); + +// 产品目录(SKU):检索 / 产品组 / 货号 / 小推车 / 图片 / 供应商 +// 必须放在 express.json() 与登录门之后 —— 写操作要记操作人(上传者 / 调价人)。 +const skuRoutes = require('./routes/sku'); +app.use('/api/sku', skuRoutes.api); +app.use('/product-images', skuRoutes.images); + +// 智能配置:多模型接入、连通测试、用途分配 +app.use('/api/ai', require('./routes/ai')); + +// 新客开发:开发结果管理(已成交 / 未成交 / 已流失) +app.use('/api/prospects', require('./routes/prospects')); + // 汇率(顶栏汇率计算器用) app.use('/api/fx', fxRoutes); +// 合同清洗中心:解析规则 / AI 兜底解析 / 批量入库 / 原件下载(上传与单条复核仍在下面) +app.use('/api/contracts', require('./routes/contracts')); + // 获取工作台数据 app.get('/api/dashboard', (req, res) => { try { + /* 工作台上的「客户」口径必须和客户模块一致,否则业务员能在这里看到别人的客户名。 + 用 'all' 取范围:有 customer.view_all 的管理账号不限归属,其余人只看自己名下。 + 待办(todos)不跟着过滤 —— 它允许没有关联客户,套上归属条件会把这类待办整片抹掉。 */ + const custScope = customerAccess.scopeFilter(req.user, 'all'); + const custWhere = custScope.conditions.length ? ' AND ' + custScope.conditions.join(' AND ') : ''; + // 统计数据 const stats = { suppliers: db.prepare('SELECT COUNT(*) as count FROM suppliers').get().count, products: db.prepare('SELECT COUNT(*) as count FROM products').get().count, - customers: db.prepare('SELECT COUNT(*) as count FROM customers').get().count, + customers: db.prepare(`SELECT COUNT(*) as count FROM customers c WHERE 1 = 1${custWhere}`).get(...custScope.params).count, orders: db.prepare(`SELECT COUNT(*) as count FROM orders WHERE status != 'cancelled'`).get().count, activeDeals: db.prepare(`SELECT COUNT(*) as count FROM deals WHERE status = 'open'`).get().count, - pendingTodos: db.prepare(`SELECT COUNT(*) as count FROM todos WHERE status = 'pending'`).get().count + pendingTodos: db.prepare(`SELECT COUNT(*) as count FROM todos WHERE status = 'pending'`).get().count, + pendingContractJobs: db.prepare(`SELECT COUNT(*) as count FROM contract_import_jobs WHERE status IN ('queued', 'parsing', 'pending_review')`).get().count }; + const todayFollowUps = db.prepare(` + SELECT f.*, c.company AS customer_name + FROM follow_up_records f JOIN customers c ON c.id = f.customer_id + WHERE substr(f.next_action_date, 1, 10) = date('now')${custWhere} + ORDER BY f.next_action_date ASC LIMIT 20 + `).all(...custScope.params); + const overdueFollowUps = db.prepare(` + SELECT f.*, c.company AS customer_name + FROM follow_up_records f JOIN customers c ON c.id = f.customer_id + WHERE f.next_action_date IS NOT NULL AND substr(f.next_action_date, 1, 10) < date('now')${custWhere} + ORDER BY f.next_action_date ASC LIMIT 20 + `).all(...custScope.params); + const recentInquiries = db.prepare(` + SELECT i.*, c.company AS customer_name FROM inquiries i + LEFT JOIN customers c ON c.id = i.customer_id ORDER BY i.created_at DESC LIMIT 10 + `).all(); + const recentQuotes = db.prepare(` + SELECT q.*, c.company AS customer_name FROM customer_quotations q + LEFT JOIN customers c ON c.id = q.customer_id ORDER BY q.created_at DESC LIMIT 10 + `).all(); + const pendingContractJobs = db.prepare(` + SELECT * FROM contract_import_jobs WHERE status IN ('queued', 'parsing', 'pending_review') + ORDER BY created_at DESC LIMIT 10 + `).all(); + // 最近活动 const recentActivities = db.prepare(` SELECT id, activity_type, subject, content, created_at @@ -119,7 +235,12 @@ app.get('/api/dashboard', (req, res) => { res.json({ stats, recentActivities, - todos + todos, + todayFollowUps, + overdueFollowUps, + recentInquiries, + recentQuotes, + pendingContractJobs }); } catch (error) { console.error('获取工作台数据失败:', error); @@ -137,7 +258,7 @@ app.get('/api/suppliers', (req, res) => { } }); -app.post('/api/suppliers', (req, res) => { +app.post('/api/suppliers', auth.requirePermission('supplier.edit'), (req, res) => { try { const { name, contact_person, phone, email, address, payment_terms, notes } = req.body; const result = db.prepare(` @@ -168,32 +289,447 @@ app.get('/api/products', (req, res) => { }); // 客户路由 -app.get('/api/customers', (req, res) => { +/* --------------------------------------------------------------------------- + * 客户归属与可见性(Wayne 2026-09-14 定) + * ① 客户有「所属」:谁建的归谁,默认只有归属人自己看得见; + * ② 创建人主动丢进公海后,同组织成员都能看见、都能领取; + * ③ 其余情况只有拿到 customer.view_all 的最高级别管理账号能查看。 + * 口径都收在 lib/customerAccess.js,这里只负责拼查询与守卫。 + * ------------------------------------------------------------------------- */ + +/** 列表用的公共 SELECT —— 归属人与创建人名字都要带出来,前端不用再打一次 */ +const CUSTOMER_SELECT = ` + SELECT c.*, + u.display_name AS owner_name, + cb.display_name AS created_by_name + FROM customers c + LEFT JOIN users u ON c.owner_id = u.id + LEFT JOIN users cb ON c.created_by = cb.id +`; + +app.get('/api/customers', (req, res, next) => { + /* 公海看的是别人的资源池,权限点跟「我的客户」不同,按 scope 选守卫 */ + const scope = String(req.query.scope || 'mine').trim(); + const key = scope === 'pool' ? 'pool.view' : 'customer.view'; + return auth.requirePermission(key)(req, res, next); +}, (req, res) => { try { + const scope = String(req.query.scope || 'mine').trim(); + if (scope === 'all' && !customerAccess.seesAll(req.user)) { + return res.status(403).json({ error: '只有最高级别系统管理账号可以查看全部客户' }); + } + const { conditions, params } = customerAccess.scopeFilter(req.user, scope); const customers = db.prepare(` - SELECT c.*, u.display_name as owner_name - FROM customers c - LEFT JOIN users u ON c.owner_id = u.id + ${CUSTOMER_SELECT} + ${conditions.length ? 'WHERE ' + conditions.join(' AND ') : ''} ORDER BY c.created_at DESC - `).all(); + `).all(...params); res.json(customers); } catch (error) { res.status(500).json({ error: error.message }); } }); -app.post('/api/customers', (req, res) => { +app.post('/api/customers', auth.requirePermission('customer.edit'), (req, res) => { try { - const { company, country, industry, website, email, phone, notes, owner_id } = req.body; + const company = String(req.body?.company || '').trim(); + if (!company) return res.status(400).json({ error: '公司名称不能为空' }); + const { country, industry, website, email, phone, notes, company_en, address, source } = req.body; + /* 只认 active / inactive;不传时保持建表默认的 active, + 这样老的调用方(客户管理页的简易表单)行为完全不变。 */ + const status = ['active', 'inactive'].includes(req.body?.status) ? req.body.status : 'active'; + /* 归属默认就是创建人自己 —— 「谁建的谁看得见」从建这一条起就成立。 + 只有拿到 customer.view_all 的管理账号才允许代别人建档。 */ + const requestedOwner = customerAccess.seesAll(req.user) ? numberOrNull(req.body?.owner_id) : null; + const ownerId = requestedOwner || (req.user ? req.user.id : null); const result = db.prepare(` - INSERT INTO customers (company, country, industry, website, email, phone, notes, owner_id, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run(company, country, industry, website, email, phone, notes, owner_id, new Date().toISOString()); + INSERT INTO customers (company, company_en, country, industry, website, address, email, phone, notes, + owner_id, created_by, org_id, source, status, pool_status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'owned', ?) + `).run(company, company_en || null, country || null, industry || null, website || null, address || null, + email || null, phone || null, notes || null, ownerId, + req.user ? req.user.id : null, req.user ? (req.user.org_id || null) : null, + source || null, status, nowIso()); + auth.audit({ + username: req.user?.username, + action: 'customer_create', + detail: `新建客户「${company}」,归属 ${req.user?.display_name || '—'}`, + req + }); + res.status(201).json({ + id: result.lastInsertRowid, + owner_id: ownerId, + message: status === 'active' ? '客户已创建并启用' : '客户已保存(未启用)' + }); + } catch (error) { jsonError(res, error); } +}); - res.json({ id: result.lastInsertRowid, message: '客户创建成功' }); - } catch (error) { - res.status(500).json({ error: error.message }); +// 编辑客户基本信息(客户详情页「基本信息」的编辑按钮) +app.patch('/api/customers/:id', auth.requirePermission('customer.edit'), (req, res) => { + try { + const customer = getCustomer(req.params.id); + if (!customer) return res.status(404).json({ error: '客户不存在' }); + if (!customerAccess.canEdit(req.user, customer)) { + return res.status(403).json({ error: '这条客户不在你名下,请先到客户公海领取' }); + } + const fields = []; + const values = []; + for (const key of ['company', 'company_en', 'country', 'industry', 'website', 'address', 'email', 'phone', 'notes', 'source']) { + if (req.body?.[key] === undefined) continue; + const value = req.body[key] === null ? '' : String(req.body[key]).trim(); + if (key === 'company' && !value) throw badRequest('公司名称不能为空'); + fields.push(`${key} = ?`); + values.push(value || null); + } + if (req.body?.owner_id !== undefined) { + /* 改归属 = 把别人的客户划走,只留给最高级别管理账号 */ + if (!customerAccess.seesAll(req.user)) throw badRequest('只有最高级别系统管理账号可以变更客户归属'); + fields.push('owner_id = ?'); values.push(numberOrNull(req.body.owner_id)); + } + if (req.body?.status !== undefined) { + /* 与新建保持一致:只认 active / inactive */ + if (!['active', 'inactive'].includes(req.body.status)) throw badRequest('status 只能是 active 或 inactive'); + fields.push('status = ?'); values.push(req.body.status); + } + if (!fields.length) throw badRequest('没有需要更新的字段'); + fields.push('updated_at = ?'); values.push(nowIso(), customer.id); + db.prepare(`UPDATE customers SET ${fields.join(', ')} WHERE id = ?`).run(...values); + res.json({ customer: getCustomer(customer.id) }); + } catch (error) { jsonError(res, error); } +}); + +/* 丢入公海:归属人主动放弃,客户变成组织共享资源 */ +app.post('/api/customers/:id/to-pool', auth.requirePermission('customer.edit'), (req, res) => { + try { + const customer = getCustomer(req.params.id); + if (!customer) return res.status(404).json({ error: '客户不存在' }); + if (!customerAccess.canEdit(req.user, customer)) { + return res.status(403).json({ error: '只有客户归属人可以把客户丢进公海' }); + } + if (customer.pool_status === 'public') throw badRequest('该客户已经在公海里了'); + const note = req.body?.note ? String(req.body.note).trim().slice(0, 200) : null; + const now = nowIso(); + db.prepare('UPDATE customers SET pool_status = ?, pool_at = ?, pool_note = ?, updated_at = ? WHERE id = ?') + .run('public', now, note, now, customer.id); + db.prepare(`INSERT INTO activities (customer_id, activity_type, content, created_at) VALUES (?, ?, ?, ?)`) + .run(customer.id, '丢入公海', `由 ${req.user?.display_name || '—'} 丢入公海${note ? `:${note}` : ''}`, now); + auth.audit({ + username: req.user?.username, + action: 'customer_to_pool', + detail: `客户「${customer.company}」丢入公海${note ? `(${note})` : ''}`, + req + }); + res.json({ ok: true, customer: getCustomer(customer.id) }); + } catch (error) { jsonError(res, error); } +}); + +/* 从公海领取:谁先领归谁 */ +app.post('/api/customers/:id/claim', auth.requirePermission('pool.claim'), (req, res) => { + try { + const customer = getCustomer(req.params.id); + if (!customer) return res.status(404).json({ error: '客户不存在' }); + if (customer.pool_status !== 'public') throw badRequest('该客户不在公海,无法领取'); + /* 已被列表过滤掉的「别组织的公海客户」,详情接口也要挡住 —— + 否则知道 id 就能直接领走,组织隔离形同虚设 */ + if (!customerAccess.inSameOrg(req.user, customer)) { + return res.status(403).json({ error: '这条客户属于其他组织的公海,你无法领取' }); + } + /* 已经在自己名下就别走这条,免得把 pool_at 抹了还以为发生了什么 */ + if (Number(customer.owner_id) === Number(req.user?.id)) throw badRequest('这条客户本来就归你'); + const now = nowIso(); + const claimed = db.prepare(` + UPDATE customers + SET owner_id = ?, pool_status = 'owned', pool_at = NULL, pool_note = NULL, updated_at = ? + WHERE id = ? AND pool_status = 'public' + `).run(req.user.id, now, customer.id); + /* 并发下可能已经被别人抢走,changes = 0 就明确回话,不要假装成功 */ + if (!claimed.changes) return res.status(409).json({ error: '这条客户刚被同事领走了,刷新看看' }); + const orgId = req.user.org_id || customer.org_id || null; + if (orgId && !customer.org_id) db.prepare('UPDATE customers SET org_id = ? WHERE id = ?').run(orgId, customer.id); + db.prepare(`INSERT INTO activities (customer_id, activity_type, content, created_at) VALUES (?, ?, ?, ?)`) + .run(customer.id, '公海领取', `由 ${req.user?.display_name || '—'} 从公海领取`, now); + auth.audit({ + username: req.user?.username, + action: 'customer_claim', + detail: `从公海领取客户「${customer.company}」`, + req + }); + res.json({ ok: true, customer: getCustomer(customer.id) }); + } catch (error) { jsonError(res, error); } +}); + +// 客户详情、联系人与跟进 +app.get('/api/customers/:id', (req, res) => { + try { + const customer = getCustomer(req.params.id); + if (!customer) return res.status(404).json({ error: '客户不存在' }); + /* 看不见的客户一律按「不存在」处理:不泄露它是否存在,也省得前端区分两种错 */ + if (!customerAccess.canView(req.user, customer)) return res.status(404).json({ error: '客户不存在' }); + const customerId = customer.id; + res.json({ + customer, + contacts: db.prepare('SELECT * FROM contacts WHERE customer_id = ? ORDER BY is_primary DESC, created_at DESC').all(customerId), + activities: db.prepare('SELECT * FROM activities WHERE customer_id = ? ORDER BY created_at DESC LIMIT 100').all(customerId), + followups: db.prepare(`SELECT f.*, ct.name AS contact_name FROM follow_up_records f LEFT JOIN contacts ct ON ct.id = f.contact_id WHERE f.customer_id = ? ORDER BY f.created_at DESC LIMIT 100`).all(customerId), + inquiries: db.prepare('SELECT * FROM inquiries WHERE customer_id = ? ORDER BY created_at DESC').all(customerId), + quoteOverview: db.prepare(`SELECT COUNT(*) AS count, COALESCE((SELECT SUM(qi.total_price) FROM quotation_items qi JOIN customer_quotations q2 ON q2.id = qi.quotation_id WHERE q2.customer_id = ?), 0) AS total_amount FROM customer_quotations WHERE customer_id = ?`).get(customerId, customerId), + orderOverview: db.prepare(`SELECT COUNT(*) AS count, COALESCE(SUM(total_amount), 0) AS total_amount FROM orders WHERE customer_id = ? AND status != 'cancelled'`).get(customerId), + quoteItems: db.prepare(`SELECT q.id, q.quote_no, q.quote_date, q.valid_until, q.currency, q.status, COALESCE((SELECT SUM(qi.total_price) FROM quotation_items qi WHERE qi.quotation_id = q.id), 0) AS total_amount FROM customer_quotations q WHERE q.customer_id = ? ORDER BY q.created_at DESC LIMIT 50`).all(customerId), + orderItems: db.prepare(`SELECT o.id, o.order_no, o.order_date, o.delivery_date, o.currency, o.status, o.total_amount FROM orders o WHERE o.customer_id = ? AND o.status != 'cancelled' ORDER BY o.order_date DESC LIMIT 50`).all(customerId) + }); + } catch (error) { jsonError(res, error); } +}); + +app.post('/api/customers/:id/contacts', auth.requirePermission('customer.edit'), (req, res) => { + try { + const customer = getCustomer(req.params.id); + if (!customer) return res.status(404).json({ error: '客户不存在' }); + const name = String(req.body?.name || '').trim(); + if (!name) return res.status(400).json({ error: '联系人姓名不能为空' }); + const fields = ['customer_id', 'name', 'name_en', 'position', 'department', 'email', 'phone', 'whatsapp', 'wechat', 'is_primary', 'notes', 'created_at']; + const values = [customer.id, name, req.body.name_en || null, req.body.position || null, req.body.department || null, req.body.email || null, req.body.phone || null, req.body.whatsapp || null, req.body.wechat || null, req.body.is_primary ? 1 : 0, req.body.notes || null, nowIso()]; + const result = db.prepare(`INSERT INTO contacts (${fields.join(',')}) VALUES (${fields.map(() => '?').join(',')})`).run(...values); + res.status(201).json({ id: result.lastInsertRowid, contact: db.prepare('SELECT * FROM contacts WHERE id = ?').get(result.lastInsertRowid) }); + } catch (error) { jsonError(res, error); } +}); + +// 编辑联系人(客户详情页「联系人」卡片上的编辑按钮) +app.patch('/api/customers/:id/contacts/:contactId', auth.requirePermission('customer.edit'), (req, res) => { + try { + const customer = getCustomer(req.params.id); + if (!customer) return res.status(404).json({ error: '客户不存在' }); + if (!customerAccess.canEdit(req.user, customer)) { + return res.status(403).json({ error: '这条客户不在你名下,请先到客户公海领取' }); + } + const contact = db.prepare('SELECT * FROM contacts WHERE id = ? AND customer_id = ?').get(Number(req.params.contactId), customer.id); + if (!contact) return res.status(404).json({ error: '联系人不存在' }); + const fields = []; + const values = []; + if (req.body?.name !== undefined) { + const name = String(req.body.name || '').trim(); + if (!name) throw badRequest('联系人姓名不能为空'); + fields.push('name = ?'); values.push(name); + } + for (const key of ['name_en', 'position', 'department', 'email', 'phone', 'whatsapp', 'wechat', 'notes']) { + if (req.body?.[key] === undefined) continue; + const value = req.body[key] === null ? '' : String(req.body[key]).trim(); + fields.push(`${key} = ?`); values.push(value || null); + } + if (req.body?.is_primary !== undefined) { fields.push('is_primary = ?'); values.push(req.body.is_primary ? 1 : 0); } + if (!fields.length) throw badRequest('没有需要更新的字段'); + db.prepare(`UPDATE contacts SET ${fields.join(', ')} WHERE id = ?`).run(...values, contact.id); + /* 设为主要联系人时把同客户的其他联系人降级,保证「主要」唯一 */ + if (req.body?.is_primary) { + db.prepare('UPDATE contacts SET is_primary = 0 WHERE customer_id = ? AND id != ?').run(customer.id, contact.id); + } + res.json({ contact: db.prepare('SELECT * FROM contacts WHERE id = ?').get(contact.id) }); + } catch (error) { jsonError(res, error); } +}); + +app.get('/api/customers/:id/follow-ups', (req, res) => { + try { + const customer = getCustomer(req.params.id); + if (!customer) return res.status(404).json({ error: '客户不存在' }); + if (!customerAccess.canView(req.user, customer)) return res.status(404).json({ error: '客户不存在' }); + res.json(db.prepare(`SELECT f.*, ct.name AS contact_name FROM follow_up_records f LEFT JOIN contacts ct ON ct.id = f.contact_id WHERE f.customer_id = ? ORDER BY COALESCE(f.next_action_date, f.created_at) DESC`).all(Number(req.params.id))); + } catch (error) { jsonError(res, error); } +}); + +app.post('/api/customers/:id/follow-ups', auth.requirePermission('customer.edit'), (req, res) => { + try { + const customer = getCustomer(req.params.id); + if (!customer) return res.status(404).json({ error: '客户不存在' }); + if (!customerAccess.canEdit(req.user, customer)) { + return res.status(403).json({ error: '这条客户不在你名下,请先到客户公海领取' }); + } + const activityType = String(req.body?.activity_type || '').trim(); + const content = String(req.body?.content || '').trim(); + if (!activityType || !content) return res.status(400).json({ error: 'activity_type 和 content 不能为空' }); + const nextDate = req.body.next_action_date ? String(req.body.next_action_date).slice(0, 10) : null; + if (nextDate && !isDate(nextDate)) return res.status(400).json({ error: 'next_action_date 格式应为 YYYY-MM-DD' }); + const contactId = numberOrNull(req.body.contact_id); + if (contactId && !db.prepare('SELECT id FROM contacts WHERE id = ? AND customer_id = ?').get(contactId, customer.id)) return res.status(400).json({ error: '联系人不属于该客户' }); + const inquiryId = numberOrNull(req.body.inquiry_id); + if (inquiryId && !db.prepare('SELECT id FROM inquiries WHERE id = ? AND customer_id = ?').get(inquiryId, customer.id)) return res.status(400).json({ error: '询盘不属于该客户' }); + const now = nowIso(); + const result = db.prepare(`INSERT INTO follow_up_records (customer_id, activity_type, content, next_action, next_action_date, contact_id, inquiry_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(customer.id, activityType, content, req.body.next_action || null, nextDate, contactId, inquiryId, now, now); + db.prepare(`INSERT INTO activities (customer_id, activity_type, content, next_action, next_action_date, created_at) VALUES (?, ?, ?, ?, ?, ?)`).run(customer.id, activityType, content, req.body.next_action || null, nextDate, now); + db.prepare('UPDATE customers SET last_contact_at = ?, updated_at = ? WHERE id = ?').run(now, now, customer.id); + res.status(201).json({ id: result.lastInsertRowid, followUp: db.prepare('SELECT * FROM follow_up_records WHERE id = ?').get(result.lastInsertRowid) }); + } catch (error) { jsonError(res, error); } +}); + +// 编辑跟进记录(时间线上每条记录的编辑按钮) +app.patch('/api/customers/:id/follow-ups/:followUpId', auth.requirePermission('customer.edit'), (req, res) => { + try { + const customer = getCustomer(req.params.id); + if (!customer) return res.status(404).json({ error: '客户不存在' }); + const record = db.prepare('SELECT * FROM follow_up_records WHERE id = ? AND customer_id = ?').get(Number(req.params.followUpId), customer.id); + if (!record) return res.status(404).json({ error: '跟进记录不存在' }); + const fields = []; + const values = []; + if (req.body?.activity_type !== undefined) { + const type = String(req.body.activity_type || '').trim(); + if (!type) throw badRequest('activity_type 不能为空'); + fields.push('activity_type = ?'); values.push(type); + } + if (req.body?.content !== undefined) { + const content = String(req.body.content || '').trim(); + if (!content) throw badRequest('content 不能为空'); + fields.push('content = ?'); values.push(content); + } + if (req.body?.next_action !== undefined) { + fields.push('next_action = ?'); values.push(req.body.next_action ? String(req.body.next_action).trim() : null); + } + if (req.body?.next_action_date !== undefined) { + const nextDate = req.body.next_action_date ? String(req.body.next_action_date).slice(0, 10) : null; + if (nextDate && !isDate(nextDate)) throw badRequest('next_action_date 格式应为 YYYY-MM-DD'); + fields.push('next_action_date = ?'); values.push(nextDate); + } + if (req.body?.contact_id !== undefined) { + const contactId = numberOrNull(req.body.contact_id); + if (contactId && !db.prepare('SELECT id FROM contacts WHERE id = ? AND customer_id = ?').get(contactId, customer.id)) throw badRequest('联系人不属于该客户'); + fields.push('contact_id = ?'); values.push(contactId); + } + if (req.body?.inquiry_id !== undefined) { + const inquiryId = numberOrNull(req.body.inquiry_id); + if (inquiryId && !db.prepare('SELECT id FROM inquiries WHERE id = ? AND customer_id = ?').get(inquiryId, customer.id)) throw badRequest('询盘不属于该客户'); + fields.push('inquiry_id = ?'); values.push(inquiryId); + } + if (!fields.length) throw badRequest('没有需要更新的字段'); + fields.push('updated_at = ?'); values.push(nowIso(), record.id); + db.prepare(`UPDATE follow_up_records SET ${fields.join(', ')} WHERE id = ?`).run(...values); + /* activities 是当时的流水快照,编辑跟进不回写它,历史记录保持原样 */ + res.json({ + followUp: db.prepare(`SELECT f.*, ct.name AS contact_name FROM follow_up_records f LEFT JOIN contacts ct ON ct.id = f.contact_id WHERE f.id = ?`).get(record.id) + }); + } catch (error) { jsonError(res, error); } +}); + +// 询盘与报价计算 +function inquiryNo() { + return `INQ-${new Date().toISOString().replace(/[-:TZ.]/g, '').slice(0, 14)}-${Math.floor(Math.random() * 900 + 100)}`; +} +function calculateInquiryItem(body, product, packaging, existing) { + const purchasePrice = body.purchase_price !== undefined ? validPositiveNumber(body.purchase_price, '采购价') : Number(existing?.purchase_price ?? product.price); + const exchangeRate = body.exchange_rate !== undefined ? validPositiveNumber(body.exchange_rate, '汇率') : Number(existing?.exchange_rate ?? 1); + const quantity = body.quantity !== undefined ? Number(body.quantity) : Number(existing?.quantity ?? 1); + if (!Number.isInteger(quantity) || quantity <= 0) throw new Error('数量必须是大于 0 的整数'); + const currency = String(body.currency !== undefined ? body.currency : (existing?.currency || product.currency || 'CNY')).trim(); + if (!currency) throw new Error('币种不能为空'); + let exw; + let markupRate; + if (body.manual_exw !== undefined && body.manual_exw !== null && body.manual_exw !== '') { + exw = validPositiveNumber(body.manual_exw, 'EXW'); + markupRate = exw / (purchasePrice / exchangeRate) - 1; + if (!Number.isFinite(markupRate) || markupRate < -1) throw new Error('手动 EXW 反算出的加价率无效'); + } else { + markupRate = body.markup_rate !== undefined ? Number(body.markup_rate) : Number(existing?.markup_rate ?? 0); + if (!Number.isFinite(markupRate) || markupRate < -1) throw new Error('加价率必须是大于等于 -1 的数字'); + exw = purchasePrice / exchangeRate * (1 + markupRate); } + if (!Number.isFinite(exw) || exw <= 0) throw new Error('EXW 计算结果无效'); + return { purchasePrice, exchangeRate, quantity, currency, markupRate, markupFactor: 1 + markupRate, exw, + packagingSnapshot: JSON.stringify(packaging || null), productSnapshot: JSON.stringify(product), supplierId: product.supplier_id || null, supplierName: product.supplier_name || null }; +} + +app.get('/api/inquiries', (req, res) => { + try { + const conditions = []; + const params = []; + if (req.query.customer_id !== undefined) { const id = Number(req.query.customer_id); if (!Number.isInteger(id) || id <= 0) return res.status(400).json({ error: 'customer_id 无效' }); conditions.push('i.customer_id = ?'); params.push(id); } + if (req.query.status) { conditions.push('i.status = ?'); params.push(String(req.query.status)); } + const rows = db.prepare(`SELECT i.*, c.company AS customer_name, COUNT(ii.id) AS item_count FROM inquiries i LEFT JOIN customers c ON c.id = i.customer_id LEFT JOIN inquiry_items ii ON ii.inquiry_id = i.id ${conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''} GROUP BY i.id ORDER BY i.created_at DESC`).all(...params); + res.json(rows); + } catch (error) { jsonError(res, error); } +}); + +app.post('/api/inquiries', auth.requirePermission('inquiry.edit'), (req, res) => { + try { + const customerId = Number(req.body?.customer_id); + if (!Number.isInteger(customerId) || customerId <= 0 || !getCustomer(customerId)) return res.status(400).json({ error: 'customer_id 无效' }); + const now = nowIso(); + const result = db.prepare(`INSERT INTO inquiries (inquiry_no, customer_id, inquiry_date, title, status, currency, source, notes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(inquiryNo(), customerId, isDate(req.body.inquiry_date) ? req.body.inquiry_date : now.slice(0, 10), req.body.title || null, req.body.status || 'draft', req.body.currency || 'USD', req.body.source || null, req.body.notes || null, now, now); + res.status(201).json({ inquiry: db.prepare('SELECT * FROM inquiries WHERE id = ?').get(result.lastInsertRowid) }); + } catch (error) { jsonError(res, error); } +}); + +app.get('/api/inquiries/:id', (req, res) => { + try { + const inquiry = db.prepare(`SELECT i.*, c.company AS customer_name FROM inquiries i LEFT JOIN customers c ON c.id = i.customer_id WHERE i.id = ?`).get(Number(req.params.id)); + if (!inquiry) return res.status(404).json({ error: '询盘不存在' }); + res.json({ inquiry, items: db.prepare('SELECT * FROM inquiry_items WHERE inquiry_id = ? ORDER BY id').all(inquiry.id) }); + } catch (error) { jsonError(res, error); } +}); + +app.patch('/api/inquiries/:id', auth.requirePermission('inquiry.edit'), (req, res) => { + try { + const id = Number(req.params.id); + if (!db.prepare('SELECT id FROM inquiries WHERE id = ?').get(id)) return res.status(404).json({ error: '询盘不存在' }); + const allowed = ['title', 'status', 'currency', 'source', 'notes', 'inquiry_date']; + const fields = []; const params = []; + for (const field of allowed) if (req.body[field] !== undefined) { if (field === 'inquiry_date' && !isDate(req.body[field])) return res.status(400).json({ error: 'inquiry_date 格式应为 YYYY-MM-DD' }); fields.push(`${field} = ?`); params.push(req.body[field]); } + if (!fields.length) return res.status(400).json({ error: '没有需要更新的字段' }); + fields.push('updated_at = ?'); params.push(nowIso(), id); + db.prepare(`UPDATE inquiries SET ${fields.join(', ')} WHERE id = ?`).run(...params); + res.json({ inquiry: db.prepare('SELECT * FROM inquiries WHERE id = ?').get(id) }); + } catch (error) { jsonError(res, error); } +}); + +app.post('/api/inquiries/:id/items', auth.requirePermission('inquiry.edit'), async (req, res) => { + try { + const inquiry = db.prepare('SELECT * FROM inquiries WHERE id = ?').get(Number(req.params.id)); + if (!inquiry) return res.status(404).json({ error: '询盘不存在' }); + const { product, packaging } = await resolveProduct(req.body?.sku); + const values = calculateInquiryItem(req.body || {}, product, packaging); + const now = nowIso(); + const result = db.prepare(`INSERT INTO inquiry_items (inquiry_id, sku, product_name, supplier_id, supplier_name, purchase_price, currency, exchange_rate, markup_rate, markup_factor, exw, quantity, packaging_snapshot, product_snapshot, quoted_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(inquiry.id, product.sku, product.name || product.name_en || product.sku, values.supplierId, values.supplierName, values.purchasePrice, values.currency, values.exchangeRate, values.markupRate, values.markupFactor, values.exw, values.quantity, values.packagingSnapshot, values.productSnapshot, now, now, now); + res.status(201).json({ item: db.prepare('SELECT * FROM inquiry_items WHERE id = ?').get(result.lastInsertRowid) }); + } catch (error) { skuError(res, error); } +}); + +app.patch('/api/inquiry-items/:id', auth.requirePermission('inquiry.edit'), async (req, res) => { + try { + const id = Number(req.params.id); + const existing = db.prepare('SELECT * FROM inquiry_items WHERE id = ?').get(id); + if (!existing) return res.status(404).json({ error: '询盘明细不存在' }); + const { product, packaging } = await resolveProduct(req.body?.sku || existing.sku); + const values = calculateInquiryItem(req.body || {}, product, packaging, existing); + const now = nowIso(); + db.prepare(`UPDATE inquiry_items SET sku = ?, product_name = ?, supplier_id = ?, supplier_name = ?, purchase_price = ?, currency = ?, exchange_rate = ?, markup_rate = ?, markup_factor = ?, exw = ?, quantity = ?, packaging_snapshot = ?, product_snapshot = ?, quoted_at = ?, updated_at = ? WHERE id = ?`).run(product.sku, product.name || product.name_en || product.sku, values.supplierId, values.supplierName, values.purchasePrice, values.currency, values.exchangeRate, values.markupRate, values.markupFactor, values.exw, values.quantity, values.packagingSnapshot, values.productSnapshot, now, now, id); + res.json({ item: db.prepare('SELECT * FROM inquiry_items WHERE id = ?').get(id) }); + } catch (error) { skuError(res, error); } +}); + +app.delete('/api/inquiry-items/:id', auth.requirePermission('inquiry.edit'), (req, res) => { + try { + const result = db.prepare('DELETE FROM inquiry_items WHERE id = ?').run(Number(req.params.id)); + if (!result.changes) return res.status(404).json({ error: '询盘明细不存在' }); + res.json({ message: '明细已删除' }); + } catch (error) { jsonError(res, error); } +}); + +// 产品包装 +// 先用 resolveProduct 解析一次:既校验 SKU 真实存在,也拿回规范化后的货号 —— +// product_packaging.sku → products.sku 这条外键要求两边写法完全一致。 +app.get('/api/products/:sku/packaging', async (req, res) => { + try { + const { product } = await resolveProduct(req.params.sku); + res.json(db.prepare('SELECT * FROM product_packaging WHERE sku = ?').get(product.sku) || { sku: product.sku }); + } catch (error) { skuError(res, error); } +}); + +app.put('/api/products/:sku/packaging', auth.requirePermission('product.edit'), async (req, res) => { + try { + const { product } = await resolveProduct(req.params.sku); + const sku = product.sku; + const allowed = ['inner_pack_qty', 'carton_qty', 'carton_length', 'carton_width', 'carton_height', 'carton_weight', 'unit_length', 'unit_width', 'unit_height', 'unit_weight', 'packaging_material', 'notes']; + const values = allowed.map(field => req.body[field] === undefined ? null : (['packaging_material', 'notes'].includes(field) ? String(req.body[field]).trim() || null : numberOrNull(req.body[field]))); + if (values.some((value, index) => value !== null && !['packaging_material', 'notes'].includes(allowed[index]) && !Number.isFinite(value))) return res.status(400).json({ error: '包装数值字段必须是数字' }); + const now = nowIso(); + db.prepare(`INSERT INTO product_packaging (sku, ${allowed.join(', ')}, created_at, updated_at) VALUES (?, ${allowed.map(() => '?').join(', ')}, ?, ?) ON CONFLICT(sku) DO UPDATE SET ${allowed.map(field => `${field} = excluded.${field}`).join(', ')}, updated_at = excluded.updated_at`).run(sku, ...values, now, now); + res.json({ packaging: db.prepare('SELECT * FROM product_packaging WHERE sku = ?').get(sku) }); + } catch (error) { skuError(res, error); } }); // 订单路由 @@ -229,11 +765,14 @@ app.get('/api/deals', (req, res) => { // Assistant read layer: every query is explicit and read-only unless a future // command is added to the allowlist below. -function getAssistantContext() { +function getAssistantContext(user) { + /* 助手读的也是同一份客户口径 —— 否则业务员绕一句「列出最近客户」就能问出别人的名单 */ + const custScope = customerAccess.scopeFilter(user, 'all'); + const custWhere = custScope.conditions.length ? ' WHERE ' + custScope.conditions.join(' AND ') : ''; const stats = { suppliers: db.prepare('SELECT COUNT(*) as count FROM suppliers').get().count, products: db.prepare('SELECT COUNT(*) as count FROM products').get().count, - customers: db.prepare('SELECT COUNT(*) as count FROM customers').get().count, + customers: db.prepare(`SELECT COUNT(*) as count FROM customers c${custWhere}`).get(...custScope.params).count, orders: db.prepare(`SELECT COUNT(*) as count FROM orders WHERE status != 'cancelled'`).get().count, deals: db.prepare(`SELECT COUNT(*) as count FROM deals WHERE status = 'open'`).get().count, todos: db.prepare(`SELECT COUNT(*) as count FROM todos WHERE status = 'pending'`).get().count @@ -244,7 +783,7 @@ function getAssistantContext() { recent: { suppliers: db.prepare('SELECT id, name, contact_person, phone FROM suppliers ORDER BY created_at DESC LIMIT 8').all(), products: db.prepare('SELECT sku, sku as id, name, brand, supplier_id, confidence as status FROM products ORDER BY created_at DESC LIMIT 8').all(), - customers: db.prepare('SELECT id, company, country, industry, status, last_contact_at FROM customers ORDER BY created_at DESC LIMIT 8').all(), + customers: db.prepare(`SELECT id, company, country, industry, status, last_contact_at FROM customers c${custWhere} ORDER BY created_at DESC LIMIT 8`).all(...custScope.params), orders: db.prepare('SELECT id, order_no, customer_id, order_date, total_amount, currency, status FROM orders ORDER BY order_date DESC LIMIT 8').all(), deals: db.prepare(`SELECT id, title, customer_id, amount, currency, stage, expected_close_date FROM deals WHERE status = 'open' ORDER BY expected_close_date LIMIT 8`).all(), todos: db.prepare(`SELECT id, title, due_date, priority, status, customer_id FROM todos WHERE status = 'pending' ORDER BY due_date ASC LIMIT 8`).all(), @@ -265,39 +804,105 @@ function getAssistantContext() { app.get('/api/assistant/context', (req, res) => { try { - res.json(getAssistantContext()); + res.json(getAssistantContext(req.user)); } catch (error) { res.status(500).json({ error: error.message }); } }); -app.post('/api/assistant/command', (req, res) => { +/** + * 本地规则答复(零配置兜底)。 + * 没接任何模型时小皮也得能干活,所以这套意图识别一直保留。 + */ +function localAssistantReply(prompt, context) { + const { stats } = context; + let reply = `已读取系统数据:${stats.customers} 个客户、${stats.products} 个产品、${stats.orders} 笔进行中订单、${stats.deals} 个开放商机、${stats.todos} 项待办。`; + let meta = 'READ_ONLY_CONTEXT'; + if (/待办|任务|日程/.test(prompt)) { + const items = context.recent.todos.slice(0, 5).map(item => item.title).join('、'); + reply = items ? `当前有 ${stats.todos} 项待办:${items}。` : '当前没有待处理的待办事项。'; + } else if (/客户/.test(prompt)) { + const items = context.recent.customers.slice(0, 5).map(item => item.company).join('、'); + reply = items ? `我找到 ${stats.customers} 个客户,最近记录包括:${items}。` : '当前还没有客户数据。'; + } else if (/产品|SKU/.test(prompt)) { + const items = context.recent.products.slice(0, 5).map(item => item.name || item.sku).join('、'); + reply = items ? `当前有 ${stats.products} 个产品,最近记录包括:${items}。` : '当前还没有产品数据。'; + } else if (/供应商/.test(prompt)) { + const items = context.recent.suppliers.slice(0, 5).map(item => item.name).join('、'); + reply = items ? `当前有 ${stats.suppliers} 个供应商,最近记录包括:${items}。` : '当前还没有供应商数据。'; + } else if (/订单/.test(prompt)) { + reply = stats.orders ? `当前有 ${stats.orders} 笔进行中订单,最近订单状态已同步到系统上下文。` : '当前没有进行中的订单。'; + } else if (/新建|新增|创建|修改|更新|删除|完成|标记/.test(prompt)) { + meta = 'CONFIRMATION_REQUIRED'; + reply = '我已经识别到这是一个可能改变系统数据的操作。当前入口已连接系统能力层,但在真正写入前需要确认具体对象、字段和目标;请先说明要操作的业务对象。'; + } + return { reply, meta }; +} + +/** 把系统上下文压成一段给大模型看的简报 —— 只带聚合数与最近几条,不外传全库 */ +function assistantSystemPrompt(context) { + const { stats, recent } = context; + const line = (label, list, pick) => { + if (!list?.length) return `${label}:暂无`; + return `${label}:${list.slice(0, 8).map(pick).join('、')}`; + }; + return [ + '你是「小皮」,外贸出口业务的系统助理,回答简洁、务实、中文优先。', + '你只能依据下面这份系统快照回答;快照里没有的信息要如实说没有,不要编造。', + '涉及写操作(新建/修改/删除)时,先复述你要改的对象与字段,等用户确认。', + '', + `客户 ${stats.customers} 个 · 产品 ${stats.products} 个 · 供应商 ${stats.suppliers} 个 · 进行中订单 ${stats.orders} 笔 · 开放商机 ${stats.deals} 个 · 待办 ${stats.todos} 项`, + line('最近客户', recent.customers, item => item.company), + line('最近产品', recent.products, item => item.name || item.sku), + line('最近供应商', recent.suppliers, item => item.name), + line('最近待办', recent.todos, item => item.title) + ].join('\n'); +} + +app.post('/api/assistant/command', async (req, res) => { try { const prompt = String(req.body?.prompt || '').trim(); if (!prompt) return res.status(400).json({ error: '请输入指令' }); - const context = getAssistantContext(); - const { stats } = context; - let reply = `已读取系统数据:${stats.customers} 个客户、${stats.products} 个产品、${stats.orders} 笔进行中订单、${stats.deals} 个开放商机、${stats.todos} 项待办。`; - let meta = 'READ_ONLY_CONTEXT'; - if (/待办|任务|日程/.test(prompt)) { - const items = context.recent.todos.slice(0, 5).map(item => item.title).join('、'); - reply = items ? `当前有 ${stats.todos} 项待办:${items}。` : '当前没有待处理的待办事项。'; - } else if (/客户/.test(prompt)) { - const items = context.recent.customers.slice(0, 5).map(item => item.company).join('、'); - reply = items ? `我找到 ${stats.customers} 个客户,最近记录包括:${items}。` : '当前还没有客户数据。'; - } else if (/产品|SKU/.test(prompt)) { - const items = context.recent.products.slice(0, 5).map(item => item.name || item.sku).join('、'); - reply = items ? `当前有 ${stats.products} 个产品,最近记录包括:${items}。` : '当前还没有产品数据。'; - } else if (/供应商/.test(prompt)) { - const items = context.recent.suppliers.slice(0, 5).map(item => item.name).join('、'); - reply = items ? `当前有 ${stats.suppliers} 个供应商,最近记录包括:${items}。` : '当前还没有供应商数据。'; - } else if (/订单/.test(prompt)) { - reply = stats.orders ? `当前有 ${stats.orders} 笔进行中订单,最近订单状态已同步到系统上下文。` : '当前没有进行中的订单。'; - } else if (/新建|新增|创建|修改|更新|删除|完成|标记/.test(prompt)) { - meta = 'CONFIRMATION_REQUIRED'; - reply = '我已经识别到这是一个可能改变系统数据的操作。当前入口已连接系统能力层,但在真正写入前需要确认具体对象、字段和目标;请先说明要操作的业务对象。'; + const context = getAssistantContext(req.user); + + /* 本地规则先算好:模型没配、或模型挂了,都能立刻回一句有用的话 */ + const local = localAssistantReply(prompt, context); + + /* 配了「智能助手对话」用途的模型就真调模型 —— 这才是智能配置的落点 */ + let modelResult = null; + try { + modelResult = await aiProviders.chatByPurpose('assistant_chat', [ + { role: 'system', content: assistantSystemPrompt(context) }, + { role: 'user', content: prompt } + ], { maxTokens: 900, temperature: 0.3, timeoutMs: 60000 }); + } catch (error) { + modelResult = { ok: false, error: error.message, model: '(未记录)' }; } - res.json({ reply, meta, context }); + + if (modelResult?.ok && modelResult.text) { + return res.json({ + reply: modelResult.text, + meta: 'MODEL', + context, + model: { + provider: modelResult.provider, + model: modelResult.model, + latency_ms: modelResult.latency_ms + } + }); + } + + if (modelResult && !modelResult.ok) { + /* 模型失败不静默:把原因带出来,用户才知道该去设置里查 */ + return res.json({ + reply: `${local.reply}\n\n(模型调用失败:${modelResult.error}。以上是本地数据直答,可在「设置 → 智能配置」检查连通性。)`, + meta: 'RULE_FALLBACK', + context, + model_error: modelResult.error + }); + } + + res.json({ reply: local.reply, meta: local.meta, context }); } catch (error) { res.status(500).json({ error: error.message }); } @@ -492,6 +1097,412 @@ app.delete('/api/todos/:id', (req, res) => { } }); +// 合同导入:认列交给通用字符规则引擎(lib/contractParser.js),不按行列位置。 +// 原件落磁盘保留 7 天(lib/contractFiles.js);解析结果才是长期数据。 +app.post('/api/contracts/import', auth.requirePermission('cleanup.view'), upload.array('files', 20), fixUploadFilenames, (req, res) => { + try { + const files = req.files || []; + if (!files.length) return res.status(400).json({ error: '请上传至少一个合同文件,字段名为 files' }); + const now = nowIso(); + const rules = contractParser.loadRules(); + const jobNo = `CIMP-${now.replace(/[-:TZ.]/g, '').slice(0, 14)}-${Math.floor(Math.random() * 900 + 100)}`; + const ruleVersion = `${Object.keys(rules.fields).length}字段词典${rules.override_source ? '+自定义' : ''}`; + const jobResult = db.prepare( + `INSERT INTO contract_import_jobs (job_no, status, file_count, source_files, parse_method, rule_version, + column_map, parse_source, created_at, updated_at) + VALUES (?, 'parsing', ?, ?, 'rules', ?, NULL, 'rules', ?, ?)` + ).run(jobNo, files.length, JSON.stringify(files.map(file => file.originalname)), ruleVersion, now, now); + + const insertItem = db.prepare( + `INSERT INTO contract_import_items (job_id, filename, row_no, raw_text, sku, product_name, spec, + purchase_price, currency, quantity, moq, status, review_note, + parse_source, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending_review', ?, 'rules', ?, ?)` + ); + /* 分批提交:每批 BATCH_SIZE 行一个事务。 + * + * 为什么不是「一个文件一个事务」:大 CSV(几千行)会让 SQLite 的写锁一次持有数秒, + * 而写锁是全库唯一的 —— 这期间其他人的每一次写操作都得排队干等。 + * 切成 400 行一批后,单次锁持有约 1–5ms,既保住批量写入的速度 + * (不必每插一条就 fsync 一次),也不会拖住任何人。 + * + * 代价是失去「整文件原子性」:中途失败时先落的批次会留在库里。 + * 但它们都带着文件名和行号、状态是 pending_review,在待复核队列里看得见也改得动 —— + * 比整批回滚更实用(用户至少能保住已解析的部分)。job 仍会被标成 parse_failed。 + * + * ⚠️ 当前规模下单文件很少超过几百行,这是防御性改造,不是救火。 */ + const BATCH_SIZE = 400; + const intOrNull = value => (value === null || value === undefined ? null : Math.round(value)); + const matched = {}; + const report = []; + let parsedFiles = 0; + + const addFile = file => { + /* 原件先落盘(保留 7 天)。保存失败不抛 —— 原件只是辅助材料, + 不能因为磁盘满/没权限就把整次导入判失败。 */ + contractFiles.saveOriginal(jobNo, file); + const result = contractParser.parseUpload(file, rules); + report.push(`${file.originalname}:表头第 ${result.header_row ?? '—'} 行,认出 ${Object.keys(result.column_map).length} 个字段,${result.rows.length} 行`); + for (const [field, hit] of Object.entries(result.column_map)) matched[field] = hit; + + if (!result.rows.length) { + insertItem.run(jobResult.lastInsertRowid, file.originalname, null, null, null, null, null, + null, null, null, null, result.notes || '没有认出表头,需人工复核', now, now); + return; + } + parsedFiles += 1; + for (let i = 0; i < result.rows.length; i += BATCH_SIZE) { + const batch = result.rows.slice(i, i + BATCH_SIZE); + db.transaction(() => { + for (const row of batch) { + insertItem.run(jobResult.lastInsertRowid, file.originalname, row.row_no, row.raw_text, + row.sku, row.product_name, row.spec, row.purchase_price, row.currency, + intOrNull(row.quantity), intOrNull(row.moq), '规则解析结果,提交前请复核', now, now); + } + })(); + } + }; + // 解析中途抛错必须把任务落成失败态。否则 job 永远停在 'parsing', + // 工作台的「待复核合同」会一直把它算成待处理项,用户点进去还看不出哪儿错了。 + try { + files.forEach(addFile); + /* column_map 把「认了哪几列、多少分」存下来 —— 这是规则引擎唯一能自证的现场, + 认错了列时用户能看到是哪一列抢了位置,再照着加排除词。 */ + const noteText = Object.keys(matched).length + ? contractParser.describeMatch(matched) + : '没有认出任何字段,建议人工补录或用 AI 解析'; + db.prepare(`UPDATE contract_import_jobs SET status = 'pending_review', column_map = ?, notes = ?, updated_at = ? WHERE id = ?`) + .run(JSON.stringify(matched), `${noteText}|${report.join(';')}`.slice(0, 1200), now, jobResult.lastInsertRowid); + } catch (parseError) { + db.prepare(`UPDATE contract_import_jobs SET status = 'parse_failed', notes = ?, updated_at = ? WHERE id = ?`) + .run(`解析中断:${parseError.message}`.slice(0, 500), now, jobResult.lastInsertRowid); + throw parseError; + } + + const rowCount = db.prepare('SELECT COUNT(*) AS count FROM contract_import_items WHERE job_id = ?') + .get(jobResult.lastInsertRowid).count; + auth.audit({ + username: req.user?.username, + action: 'contract_import', + detail: `导入合同 ${files.length} 个文件(任务 ${jobNo}),解析出 ${rowCount} 行,认列:${Object.keys(matched).length} 个字段`, + req + }); + res.status(201).json({ + job: db.prepare('SELECT * FROM contract_import_jobs WHERE id = ?').get(jobResult.lastInsertRowid), + message: parsedFiles + ? '已按字符规则解析,进入待复核队列' + : '文件已收下,但规则没认出头:可点「AI 解析」或直接人工补录' + }); + } catch (error) { jsonError(res, error); } +}); + +app.get('/api/contracts/import-jobs', (req, res) => { + try { res.json(db.prepare('SELECT * FROM contract_import_jobs ORDER BY created_at DESC LIMIT 100').all()); } catch (error) { jsonError(res, error); } +}); + +app.get('/api/contracts/import-jobs/:id', (req, res) => { + try { + const job = db.prepare('SELECT * FROM contract_import_jobs WHERE id = ?').get(Number(req.params.id)); + if (!job) return res.status(404).json({ error: '导入任务不存在' }); + res.json({ job, items: db.prepare('SELECT * FROM contract_import_items WHERE job_id = ? ORDER BY filename, row_no, id').all(job.id) }); + } catch (error) { jsonError(res, error); } +}); + +app.patch('/api/contracts/import-items/:id/review', auth.requirePermission('cleanup.view'), (req, res) => { + try { + const id = Number(req.params.id); + const item = db.prepare('SELECT * FROM contract_import_items WHERE id = ?').get(id); + if (!item) return res.status(404).json({ error: '导入明细不存在' }); + const status = ['approved', 'rejected', 'pending_review'].includes(req.body?.status) ? req.body.status : null; + if (!status) return res.status(400).json({ error: 'status 必须是 approved、rejected 或 pending_review' }); + const now = nowIso(); + db.prepare(`UPDATE contract_import_items SET status = ?, review_note = ?, sku = ?, product_name = ?, purchase_price = ?, currency = ?, quantity = ?, reviewed_at = ?, updated_at = ? WHERE id = ?`).run(status, req.body.review_note || null, req.body.sku ?? item.sku, req.body.product_name ?? item.product_name, req.body.purchase_price === undefined ? item.purchase_price : numberOrNull(req.body.purchase_price), req.body.currency ?? item.currency, req.body.quantity === undefined ? item.quantity : numberOrNull(req.body.quantity), now, now, id); + const remaining = db.prepare(`SELECT COUNT(*) AS count FROM contract_import_items WHERE job_id = ? AND status = 'pending_review'`).get(item.job_id).count; + db.prepare('UPDATE contract_import_jobs SET status = ?, updated_at = ?, completed_at = ? WHERE id = ?').run(remaining ? 'pending_review' : 'reviewed', now, remaining ? null : now, item.job_id); + res.json({ item: db.prepare('SELECT * FROM contract_import_items WHERE id = ?').get(id) }); + } catch (error) { jsonError(res, error); } +}); + +/* ============================== 客户报价单 ============================== */ + +/** 报价单合计一律实时从明细汇总 —— 主表不冗余总价,避免两处口径打架 */ +const QUOTE_TOTAL_SQL = `COALESCE((SELECT SUM(qi.total_price) FROM quotation_items qi WHERE qi.quotation_id = q.id), 0)`; +const QUOTE_STATUSES = ['draft', 'sent', 'accepted', 'rejected', 'expired']; + +function roundTo(value, digits) { + const factor = Math.pow(10, digits); + return Math.round(Number(value) * factor) / factor; +} + +/** 生成报价单号:QT-YYYYMMDD-NNN,同一天内递增 */ +function nextQuoteNo() { + const stamp = new Date().toISOString().slice(0, 10).replace(/-/g, ''); + const prefix = `QT-${stamp}-`; + const last = db.prepare('SELECT quote_no FROM customer_quotations WHERE quote_no LIKE ? ORDER BY quote_no DESC LIMIT 1').get(`${prefix}%`); + const tail = last ? String(last.quote_no).slice(prefix.length) : ''; + const seq = /^\d+$/.test(tail) ? Number(tail) + 1 : 1; + return `${prefix}${String(seq).padStart(3, '0')}`; +} + +/** + * 明细行单价:采购价 ÷ 汇率 × (1 + 加价率)。 + * 与询盘报价是同一套口径(inquiry_items 的 exw 就是这么算的),不要在这里另立一套。 + */ +function priceFromCost(purchasePrice, exchangeRate, markupRate) { + const cost = Number(purchasePrice); + const rate = Number(exchangeRate); + if (!Number.isFinite(cost) || cost <= 0) return null; + if (!Number.isFinite(rate) || rate <= 0) return null; + const markup = Number(markupRate); + return roundTo(cost / rate * (1 + (Number.isFinite(markup) ? markup : 0)), 4); +} + +/** 百分比(0-100);空值返回 null,便于区分「没填」和「填了 0」 */ +function percentOrNull(value, label) { + if (value === undefined || value === null || value === '') return null; + const num = Number(value); + if (!Number.isFinite(num) || num < 0 || num > 100) throw badRequest(`${label}必须在 0 到 100 之间`); + return roundTo(num, 4); +} + +/** + * 结构化付款条件 → 一句可读文本,写进 payment_terms 列。 + * 报价单列表 / 导出直接展示这一列,所以文本要能独立读懂。 + * T/T + 30% → "T/T 30% 预付款,70% 尾款";100% → "T/T 100% 预付款(全额预付)"。 + */ +function composePaymentTerms(method, deposit, balance) { + if (!method) return null; + if (deposit === null) return method; + if (deposit >= 100) return `${method} 100% 预付款(全额预付)`; + return `${method} ${deposit}% 预付款,${balance === null ? 100 - deposit : balance}% 尾款`; +} + +/** 交货方式 + 地点 → "FOB Shanghai" */ +function composeDeliveryTerms(method, port) { + const text = [method, port].filter(Boolean).join(' ').trim(); + return text || null; +} + +app.get('/api/quotes', (req, res) => { + try { + res.json(db.prepare(` + SELECT q.*, c.company AS customer_company, c.country AS customer_country, c.email AS customer_email, + ${QUOTE_TOTAL_SQL} AS total_amount, + (SELECT COUNT(*) FROM quotation_items qi WHERE qi.quotation_id = q.id) AS item_count + FROM customer_quotations q + LEFT JOIN customers c ON c.id = q.customer_id + ORDER BY q.created_at DESC LIMIT 200 + `).all()); + } catch (error) { jsonError(res, error); } +}); + +app.get('/api/quotes/:id', (req, res) => { + try { + const quote = db.prepare(`SELECT q.*, c.company AS customer_company FROM customer_quotations q LEFT JOIN customers c ON c.id = q.customer_id WHERE q.id = ?`).get(Number(req.params.id)); + if (!quote) return res.status(404).json({ error: '报价单不存在' }); + res.json({ + quote, + customer: quote.customer_id ? db.prepare('SELECT * FROM customers WHERE id = ?').get(quote.customer_id) : null, + contacts: quote.customer_id ? db.prepare('SELECT * FROM contacts WHERE customer_id = ? ORDER BY is_primary DESC, id').all(quote.customer_id) : [], + items: db.prepare('SELECT * FROM quotation_items WHERE quotation_id = ? ORDER BY id').all(quote.id) + }); + } catch (error) { jsonError(res, error); } +}); + +app.post('/api/quotes', auth.requirePermission('quote.edit'), (req, res) => { + try { + const customer = getCustomer(req.body?.customer_id); + if (!customer) return res.status(400).json({ error: '请选择客户(customer_id 无效)' }); + + const rawItems = Array.isArray(req.body?.items) ? req.body.items : []; + if (!rawItems.length) return res.status(400).json({ error: '报价明细不能为空' }); + + const currency = String(req.body?.currency || 'USD').trim().toUpperCase(); + if (!/^[A-Z]{3}$/.test(currency)) return res.status(400).json({ error: '币种必须是 3 位字母代码' }); + + const quoteDate = String(req.body?.quote_date || new Date().toISOString().slice(0, 10)).slice(0, 10); + if (!isDate(quoteDate)) return res.status(400).json({ error: 'quote_date 格式应为 YYYY-MM-DD' }); + let validUntil = req.body?.valid_until ? String(req.body.valid_until).slice(0, 10) : null; + if (validUntil && !isDate(validUntil)) return res.status(400).json({ error: 'valid_until 格式应为 YYYY-MM-DD' }); + + /* 有效期天数与截止日期互为兜底:只给天数就自己算日期;两个都给则以日期为准重算天数,避免两处打架 */ + let validityDays = null; + if (req.body?.validity_days !== undefined && req.body?.validity_days !== null && req.body?.validity_days !== '') { + const days = Number(req.body.validity_days); + if (!Number.isInteger(days) || days <= 0) throw badRequest('有效期天数必须是大于 0 的整数'); + validityDays = days; + } + if (!validUntil && validityDays) { + validUntil = new Date(Date.parse(quoteDate) + validityDays * 86400000).toISOString().slice(0, 10); + } else if (validUntil) { + validityDays = Math.max(0, Math.round((Date.parse(validUntil) - Date.parse(quoteDate)) / 86400000)); + } + if (validUntil && validUntil < quoteDate) throw badRequest('有效期不能早于报价日期'); + + /* 付款条件:预付 + 尾款必须凑满 100,否则存进去的是一笔算不平的账 */ + const paymentMethod = req.body?.payment_method ? String(req.body.payment_method).trim() : null; + const depositPercent = percentOrNull(req.body?.deposit_percent, '预付款比例'); + const balancePercent = percentOrNull(req.body?.balance_percent, '尾款比例'); + if (depositPercent !== null && balancePercent !== null && Math.abs(depositPercent + balancePercent - 100) > 0.001) { + throw badRequest('预付款与尾款比例之和必须是 100%'); + } + const paymentTermsText = paymentMethod + ? composePaymentTerms(paymentMethod, depositPercent, balancePercent) + : (req.body?.payment_terms ? String(req.body.payment_terms).trim() : null); + + const deliveryMethod = req.body?.delivery_method ? String(req.body.delivery_method).trim() : null; + const deliveryPort = req.body?.delivery_port ? String(req.body.delivery_port).trim() : null; + const deliveryTermsText = (deliveryMethod || deliveryPort) + ? composeDeliveryTerms(deliveryMethod, deliveryPort) + : (req.body?.delivery_terms ? String(req.body.delivery_terms).trim() : null); + + const status = QUOTE_STATUSES.includes(req.body?.status) ? req.body.status : 'draft'; + + /* 每条明细先校验干净再进事务 —— 不让半截数据落库 */ + const items = rawItems.map((raw, index) => { + const label = `第 ${index + 1} 行`; + const sku = String(raw?.sku || '').trim(); + if (!sku) throw badRequest(`${label}缺少货号`); + const qty = Number(raw?.qty); + if (!Number.isInteger(qty) || qty <= 0) throw badRequest(`${label}的数量必须是大于 0 的整数`); + + const pick = key => { + const value = raw?.[key]; + if (value === undefined || value === null || value === '') return null; + const num = Number(value); + return Number.isFinite(num) ? num : null; + }; + const purchasePrice = pick('purchase_price'); + const exchangeRate = pick('exchange_rate'); + const markupRate = pick('markup_rate'); + + const provided = pick('unit_price'); + // 前端为了即时预览会自己算一次并传上来;没传就按同一口径在后端补算,兜住 + const unitPrice = provided !== null ? provided : priceFromCost(purchasePrice, exchangeRate, markupRate); + if (unitPrice === null || unitPrice < 0) { + throw badRequest(`${label}的单价无法确定:请填单价,或同时填齐采购价 / 汇率 / 加价率`); + } + + return { + sku, + productName: String(raw?.product_name || sku).trim(), + productNameEn: raw?.product_name_en ? String(raw.product_name_en).trim() : null, + spec: raw?.spec ? String(raw.spec).trim() : null, + qty, + unitPrice: roundTo(unitPrice, 4), + totalPrice: roundTo(unitPrice * qty, 2), + currency, + purchasePrice, exchangeRate, markupRate, + notes: raw?.notes ? String(raw.notes).trim() : null + }; + }); + + const now = nowIso(); + const create = db.transaction(() => { + const quoteNo = nextQuoteNo(); + const result = db.prepare(`INSERT INTO customer_quotations (quote_no, customer_id, quote_date, valid_until, validity_days, currency, pi_no, payment_method, deposit_percent, balance_percent, payment_terms, delivery_method, delivery_port, delivery_terms, status, notes, internal_notes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) + .run(quoteNo, customer.id, quoteDate, validUntil, validityDays, currency, + req.body?.pi_no ? String(req.body.pi_no).trim() : null, + paymentMethod, depositPercent, balancePercent, paymentTermsText, + deliveryMethod, deliveryPort, deliveryTermsText, + status, + req.body?.notes ? String(req.body.notes).trim() : null, + req.body?.internal_notes ? String(req.body.internal_notes).trim() : null, + now, now); + const quotationId = result.lastInsertRowid; + const insertItem = db.prepare(`INSERT INTO quotation_items (quotation_id, sku, product_name, product_name_en, spec, qty, unit_price, total_price, currency, purchase_price, exchange_rate, markup_rate, notes, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`); + for (const item of items) { + insertItem.run(quotationId, item.sku, item.productName, item.productNameEn, item.spec, item.qty, item.unitPrice, item.totalPrice, item.currency, item.purchasePrice, item.exchangeRate, item.markupRate, item.notes, now); + } + return quotationId; + }); + + const quotationId = create(); + res.status(201).json({ + id: quotationId, + quote: db.prepare('SELECT * FROM customer_quotations WHERE id = ?').get(quotationId), + items: db.prepare('SELECT * FROM quotation_items WHERE quotation_id = ? ORDER BY id').all(quotationId) + }); + } catch (error) { jsonError(res, error); } +}); + +app.patch('/api/quotes/:id', auth.requirePermission('quote.edit'), (req, res) => { + try { + const id = Number(req.params.id); + if (!db.prepare('SELECT id FROM customer_quotations WHERE id = ?').get(id)) return res.status(404).json({ error: '报价单不存在' }); + const fields = []; + const values = []; + if (req.body?.status !== undefined) { + if (!QUOTE_STATUSES.includes(req.body.status)) return res.status(400).json({ error: `status 必须是 ${QUOTE_STATUSES.join(' / ')}` }); + fields.push('status = ?'); values.push(req.body.status); + } + /* 有效期:天数与日期互为兜底,和 POST 保持同一套口径 */ + if (req.body?.valid_until !== undefined || req.body?.validity_days !== undefined) { + const current = db.prepare('SELECT quote_date FROM customer_quotations WHERE id = ?').get(id); + let validUntil = req.body?.valid_until ? String(req.body.valid_until).slice(0, 10) : null; + if (validUntil && !isDate(validUntil)) throw badRequest('valid_until 格式应为 YYYY-MM-DD'); + let days = null; + if (req.body?.validity_days !== undefined && req.body?.validity_days !== null && req.body?.validity_days !== '') { + const n = Number(req.body.validity_days); + if (!Number.isInteger(n) || n <= 0) throw badRequest('有效期天数必须是大于 0 的整数'); + days = n; + } + const base = current?.quote_date || new Date().toISOString().slice(0, 10); + if (!validUntil && days) validUntil = new Date(Date.parse(base) + days * 86400000).toISOString().slice(0, 10); + else if (validUntil) days = Math.max(0, Math.round((Date.parse(validUntil) - Date.parse(base)) / 86400000)); + fields.push('valid_until = ?'); values.push(validUntil); + fields.push('validity_days = ?'); values.push(days); + } + /* 付款条件整组重拼:只传了部分字段(例如只改比例)时用库里的现值补齐,不能顺手清空 */ + if (req.body?.payment_method !== undefined || req.body?.deposit_percent !== undefined || req.body?.balance_percent !== undefined) { + const existing = db.prepare('SELECT payment_method, deposit_percent, balance_percent FROM customer_quotations WHERE id = ?').get(id); + const method = req.body?.payment_method !== undefined + ? (req.body.payment_method ? String(req.body.payment_method).trim() : null) + : (existing?.payment_method || null); + const deposit = req.body?.deposit_percent !== undefined + ? percentOrNull(req.body.deposit_percent, '预付款比例') + : (existing?.deposit_percent ?? null); + const balance = req.body?.balance_percent !== undefined + ? percentOrNull(req.body.balance_percent, '尾款比例') + : (existing?.balance_percent ?? null); + if (deposit !== null && balance !== null && Math.abs(deposit + balance - 100) > 0.001) throw badRequest('预付款与尾款比例之和必须是 100%'); + fields.push('payment_method = ?'); values.push(method); + fields.push('deposit_percent = ?'); values.push(deposit); + fields.push('balance_percent = ?'); values.push(balance); + fields.push('payment_terms = ?'); values.push(composePaymentTerms(method, deposit, balance)); + } + /* 交货条件同上:方式或地点任一变了就重拼 delivery_terms */ + if (req.body?.delivery_method !== undefined || req.body?.delivery_port !== undefined) { + const existing = db.prepare('SELECT delivery_method, delivery_port FROM customer_quotations WHERE id = ?').get(id); + const method = req.body?.delivery_method !== undefined + ? (req.body.delivery_method ? String(req.body.delivery_method).trim() : null) + : (existing?.delivery_method || null); + const port = req.body?.delivery_port !== undefined + ? (req.body.delivery_port ? String(req.body.delivery_port).trim() : null) + : (existing?.delivery_port || null); + fields.push('delivery_method = ?'); values.push(method); + fields.push('delivery_port = ?'); values.push(port); + fields.push('delivery_terms = ?'); values.push(composeDeliveryTerms(method, port)); + } + for (const key of ['pi_no', 'notes', 'internal_notes']) { + if (req.body?.[key] !== undefined) { + const value = req.body[key] === null ? '' : String(req.body[key]).trim(); + fields.push(`${key} = ?`); values.push(value || null); + } + } + if (!fields.length) return res.status(400).json({ error: '没有需要更新的字段' }); + fields.push('updated_at = ?'); values.push(nowIso(), id); + db.prepare(`UPDATE customer_quotations SET ${fields.join(', ')} WHERE id = ?`).run(...values); + res.json({ quote: db.prepare('SELECT * FROM customer_quotations WHERE id = ?').get(id) }); + } catch (error) { jsonError(res, error); } +}); + +app.use((error, req, res, next) => { + if (res.headersSent) return next(error); + console.error('API 请求失败:', error); + jsonError(res, error); +}); + // SPA fallback - 所有未匹配的路由返回 index.html app.get('*', (req, res) => { res.sendFile(path.join(__dirname, '../frontend/dist/index.html')); @@ -505,5 +1516,8 @@ app.listen(PORT, HOST, () => { console.log(` 服务器地址: http://${HOST}:${PORT}`); console.log(` 启动时间: ${new Date().toLocaleString('zh-CN')}`); console.log('========================================'); + /* 合同原件只留 7 天:启动清一次,之后每 6 小时一次(见 lib/contractFiles.js)。 + 放这里而不是定时任务里 —— 自建 VPS 上不一定有 cron,服务起来就该自己管好磁盘。 */ + contractFiles.startPurgeSchedule(db); }); diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index ec8eab6..1664e67 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -40,7 +40,7 @@ 1. **安装依赖并启动** ```bash - cd D:\wayne\Personal\trade-management-system + cd D:\wayne\Personal\happy-export # 安装后端依赖 cd backend diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index debe2d0..d1c2252 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,20 +1,14 @@ import React from 'react' -import { BrowserRouter as Router, Routes, Route, NavLink, Navigate } from 'react-router-dom' -import Dashboard from './pages/Dashboard' -import EmbeddedSku from './pages/EmbeddedSku' -import ProductManagement from './pages/ProductManagement' -import SupplierManagement from './pages/SupplierManagement' -import CustomerManagement from './pages/CustomerManagement' -import OrderManagement from './pages/OrderManagement' -import ProspectManagement from './pages/ProspectManagement' -import QuoteManagement from './pages/QuoteManagement' -import PiContractManagement from './pages/PiContractManagement' -import PurchaseOrderManagement from './pages/PurchaseOrderManagement' +import { BrowserRouter as Router, Routes, Route, NavLink, Navigate, useNavigate, useLocation } from 'react-router-dom' +import LoginPage from './pages/LoginPage' import Settings from './components/Settings' import GlobalHeader from './components/GlobalHeader' import AIAssistant from './components/AIAssistant' import { readPreference } from './preferences' -import { NAV_GROUPS } from './navigation' +import { AuthProvider, useAuth, hasPermission } from './auth' +import { loadBranding, useBranding, setPageTitle } from './branding' +import { NAV_GROUPS, ROUTE_PERMISSIONS, HOME_PATH, PAGE_LABELS } from './navigation' +import { getUnsavedGuard, isGuardDirty, clearUnsavedGuard } from './unsavedGuard' import { DashboardIcon, ChevronDownIcon, @@ -24,28 +18,180 @@ import { SidebarLayoutIcon } from './components/Icons' -function App() { +/* --------------------------------------------------------------------------- + * 路由级代码分割 + * + * 改造前:18 个页面全是静态 import,Vite 把整站打进一个 index.js(约 530 KB)。 + * 打开任何一页,浏览器都得先把整站的代码下载并解析完才看得到东西 —— + * 本地感觉不出来,上云走公网就是一两秒白屏。 + * + * 改成 React.lazy 后每页一个独立 chunk,首屏只加载当前页那一份。 + * 下面几个刻意保持静态 import,不拆,理由各自不同: + * · LoginPage —— 未登录时它本身就是首屏,拆了反而多一次往返 + * · GlobalHeader / Settings / AIAssistant —— 属于外壳,首屏就要用 + * --------------------------------------------------------------------------- */ +const Dashboard = React.lazy(() => import('./pages/Dashboard')) +const ProductManagement = React.lazy(() => import('./pages/ProductManagement')) +const SupplierManagement = React.lazy(() => import('./pages/SupplierManagement')) +const CustomerManagement = React.lazy(() => import('./pages/CustomerManagement')) +const CustomerDetail = React.lazy(() => import('./pages/CustomerDetail')) +const InquiryManagement = React.lazy(() => import('./pages/InquiryManagement')) +const InquiryDetail = React.lazy(() => import('./pages/InquiryDetail')) +const ContractCleanup = React.lazy(() => import('./pages/ContractCleanup')) +const OrderManagement = React.lazy(() => import('./pages/OrderManagement')) +const ProspectManagement = React.lazy(() => import('./pages/ProspectManagement')) +const QuoteManagement = React.lazy(() => import('./pages/QuoteManagement')) +const QuoteEditor = React.lazy(() => import('./pages/QuoteEditor')) +const PiContractManagement = React.lazy(() => import('./pages/PiContractManagement')) +const PurchaseOrderManagement = React.lazy(() => import('./pages/PurchaseOrderManagement')) +const CustomerPool = React.lazy(() => import('./pages/CustomerPool')) +const ProfilePage = React.lazy(() => import('./pages/ProfilePage')) +const UserManagement = React.lazy(() => import('./pages/UserManagement')) +const PermissionGroups = React.lazy(() => import('./pages/PermissionGroups')) +const SystemLogs = React.lazy(() => import('./pages/SystemLogs')) + +/* -------------------------------------------------------------- 启动与兜底 */ + +function BootScreen() { + const brand = useBranding() + return ( +
+ +

正在载入 {brand.name}…

+
+ ) +} + +/* 详情页路径不在导航表里,给它们一批前缀标题,免得浏览器标签只剩系统名 */ +const DETAIL_TITLES = [ + ['/customers/', '客户详情'], + ['/inquiries/', '询盘详情'], + ['/products/', '产品详情'], + ['/suppliers/', '供应商详情'] +] + +function pageTitleFor(pathname) { + if (PAGE_LABELS[pathname]) return PAGE_LABELS[pathname] + const hit = DETAIL_TITLES.find(([prefix]) => pathname.startsWith(prefix)) + return hit ? hit[1] : '' +} + +/** 路由懒加载时的过渡态:不白屏,给一条顶部进度线 + 一句提示 */ +function RouteFallback() { + return ( +
+
+

正在载入…

+
+ ) +} + +function NoPermission() { + return ( +
+
+
无访问权限
+

+ 当前角色所属的权限组没有该模块的查看权限。请让管理员在「权限组管理」里把该角色指派到有权限的组。 +

+
+
+ ) +} + +/** 路由级权限守卫:与侧栏用的是同一份 ROUTE_PERMISSIONS,不会两处不一致 */ +function Guarded({ permission, children }) { + const { user } = useAuth() + if (permission && !hasPermission(user, permission)) return + return children +} + +/* -------------------------------------------------------------- 主外壳 */ + +function Shell({ theme, onThemeChange }) { + const { user } = useAuth() + const navigate = useNavigate() + const location = useLocation() const [collapsed, setCollapsed] = React.useState(false) const [collapsedGroups, setCollapsedGroups] = React.useState([]) const [settingsOpen, setSettingsOpen] = React.useState(false) const [assistantOpen, setAssistantOpen] = React.useState(false) - const [theme, setTheme] = React.useState(() => readPreference('tms-theme', ['dark', 'light'], 'dark')) + const [pendingNav, setPendingNav] = React.useState(null) + const [guardBusy, setGuardBusy] = React.useState(false) + const [reducedMotion, setReducedMotion] = React.useState( + () => readPreference('tms-motion', ['full', 'reduced'], 'full') === 'reduced' + ) - const [reducedMotion, setReducedMotion] = React.useState(() => readPreference('tms-motion', ['full', 'reduced'], 'full') === 'reduced') const closeSettings = React.useCallback(() => setSettingsOpen(false), []) const closeAssistant = React.useCallback(() => setAssistantOpen(false), []) - React.useEffect(() => { - document.documentElement.dataset.theme = theme - document.documentElement.style.colorScheme = theme - try { localStorage.setItem('tms-theme', theme) } catch { /* Session-only when storage is unavailable. */ } - }, [theme]) - React.useEffect(() => { document.documentElement.dataset.motion = reducedMotion ? 'reduced' : 'full' try { localStorage.setItem('tms-motion', reducedMotion ? 'reduced' : 'full') } catch { /* Session-only. */ } }, [reducedMotion]) + /* 浏览器标签标题跟着当前页走 —— 页面组件内部也会设一次(同值), + 这里兜住那些没自己设标题的页面,避免标签一直停在上一页的名字。 */ + React.useEffect(() => { + setPageTitle(pageTitleFor(location.pathname)) + }, [location.pathname]) + + /* ------------------------------------------------------------------ + 未保存改动拦截 + 编辑页(个人资料)挂载时会在 unsavedGuard 里登记「我脏了吗 / 帮我存 / 帮我丢弃」。 + 两道防线: + 1) 所有应用内跳转都走 guardedNavigate,跳之前先问一句; + 2) 浏览器后退/前进这类不走我们的导航,靠监听 location 变化把它弹回来。 + ------------------------------------------------------------------ */ + const guardedNavigate = React.useCallback((path) => { + if (!path || path === location.pathname) return + if (isGuardDirty()) { setPendingNav(path); return } + navigate(path) + }, [navigate, location.pathname]) + + const lastPathRef = React.useRef(location.pathname) + React.useEffect(() => { + const previous = lastPathRef.current + lastPathRef.current = location.pathname + /* 只在「刚刚离开编辑页」且「它还有未保存改动」时弹回来 */ + if (previous === location.pathname || previous !== '/profile') return + if (!isGuardDirty()) return + setPendingNav(location.pathname) + navigate('/profile', { replace: true }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [location.pathname]) + + React.useEffect(() => { + const handler = event => { + if (!isGuardDirty()) return + event.preventDefault() + event.returnValue = '' + } + window.addEventListener('beforeunload', handler) + return () => window.removeEventListener('beforeunload', handler) + }, []) + + const resolvePending = async (action) => { + const target = pendingNav + const guard = getUnsavedGuard() + setPendingNav(null) + if (action === 'cancel') return + if (action === 'save') { + setGuardBusy(true) + try { + await guard?.save() + } catch { + setGuardBusy(false) + return + } + setGuardBusy(false) + } else { + guard?.discard() + } + clearUnsavedGuard() + if (target) navigate(target) + } + const navClass = ({ isActive }) => `nav-item${isActive ? ' active' : ''}` /* 工作台额外带一个类,供「展开/收起导航按钮挂在它右侧」定位 */ const homeNavClass = ({ isActive }) => `nav-item nav-item-home${isActive ? ' active' : ''}` @@ -54,113 +200,203 @@ function App() { setCollapsedGroups(current => current.includes(id) ? current.filter(x => x !== id) : [...current, id]) }, []) + /* 侧栏只显示当前权限组可见的菜单;整组都不可见就整组隐藏 */ + const visibleGroups = React.useMemo( + () => NAV_GROUPS + .map(group => ({ ...group, items: group.items.filter(item => hasPermission(user, item.permission)) })) + .filter(group => group.items.length > 0), + [user] + ) + + const guard = (path, element) => {element} + return ( - -
- {/* 只有设置是模态;「小皮」是浮窗,不能再 inert 主界面,否则没法边聊边操作 */} -
- {/* 顶栏横跨整宽,压在侧边栏之上 */} - setAssistantOpen(true)} /> +
+ {/* 只有设置是模态;「小皮」是浮窗,不能再 inert 主界面,否则没法边聊边操作 */} +
+ {/* 顶栏横跨整宽,压在侧边栏之上 */} + setAssistantOpen(true)} onNavigate={guardedNavigate} /> -
- -
-
- - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - -
-
+
+
+ {/* 页面 chunk 加载期间显示过渡态,避免白屏 */} + }> + + } /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + } /> + )} /> + )} /> + )} /> + } /> + + +
+
+
+
+ + {/* 未保存改动的离开确认:保存并离开 / 放弃修改 / 取消 */} + {pendingNav && ( +
resolvePending('cancel')}> +
event.stopPropagation()}> +
+

个人资料还没保存

+
+
+

你修改了个人资料但还没有保存。要保存后再离开吗?

+

选「放弃修改」会丢掉刚才的改动,恢复成保存前的样子。

+
+
+ + + +
+ )} - - -
- + + +
+ ) +} + +/* -------------------------------------------------------------- 分流 */ + +function AppRoutes({ theme, onThemeChange }) { + const { user, loading } = useAuth() + + if (loading) return + if (!user) { + /* 未登录时任何路径都落到登录页 */ + return ( + + } /> + + ) + } + return +} + +function App() { + const [theme, setTheme] = React.useState(() => readPreference('tms-theme', ['dark', 'light'], 'dark')) + + /* 品牌信息(系统显示名/副标题)启动就拉一次:登录页与顶栏都要用,且改完要即时生效。 + 它是公开接口,未登录也能拿到;失败就用兜底名,不影响进系统。 */ + React.useEffect(() => { loadBranding() }, []) + + React.useEffect(() => { + document.documentElement.dataset.theme = theme + document.documentElement.style.colorScheme = theme + try { localStorage.setItem('tms-theme', theme) } catch { /* Session-only when storage is unavailable. */ } + }, [theme]) + + return ( + + + + + ) } export default App - - - - - diff --git a/frontend/src/amountInWords.js b/frontend/src/amountInWords.js new file mode 100644 index 0000000..e3f0658 --- /dev/null +++ b/frontend/src/amountInWords.js @@ -0,0 +1,142 @@ +/** + * 金额英文大写(外贸单证用) + * + * 目标格式与信用证 / 发票上的写法一致: + * 255.36 USD → SAY US DOLLARS TWO HUNDRED AND FIFTY-FIVE AND THIRTY-SIX CENTS ONLY + * 1000 CNY → SAY CHINESE YUAN ONE THOUSAND ONLY + * 0.5 EUR → SAY EUROS ZERO AND FIFTY CENTS ONLY + * + * 几条实务约定(不是「随便翻译一下」): + * 1. 整句以 SAY 开头、以 ONLY 结尾,这是单证上的固定套路。 + * 2. 百位与十位之间用 AND(英式写法),如 TWO HUNDRED AND FIFTY-FIVE。 + * 「组」与「组」之间不重复加 AND,避免 ONE THOUSAND AND TWO HUNDRED AND ... 这种啰嗦写法。 + * 3. 货币名固定用复数形式(US DOLLARS / EUROS),因为这里是货币名称而非计量词, + * 单证上不写 ONE US DOLLAR。 + * 4. 日元等无小数币种不带辅币段;小数位按币种配置取。 + */ + +/* 0-19 单独记,20 以上由十位 + 个位拼 */ +const ONES = [ + '', 'ONE', 'TWO', 'THREE', 'FOUR', 'FIVE', 'SIX', 'SEVEN', 'EIGHT', 'NINE', + 'TEN', 'ELEVEN', 'TWELVE', 'THIRTEEN', 'FOURTEEN', 'FIFTEEN', 'SIXTEEN', + 'SEVENTEEN', 'EIGHTEEN', 'NINETEEN' +] +const TENS = ['', '', 'TWENTY', 'THIRTY', 'FORTY', 'FIFTY', 'SIXTY', 'SEVENTY', 'EIGHTY', 'NINETY'] +const SCALES = ['', 'THOUSAND', 'MILLION', 'BILLION', 'TRILLION'] + +/** + * 币种配置。 + * head —— 大写句里跟在 SAY 后面的货币名(固定复数) + * centsHead —— 辅币名(固定复数) + * decimals —— 该币种的小数位(日元等为 0) + */ +const CURRENCY_WORDS = { + USD: { head: 'US DOLLARS', centsHead: 'CENTS', decimals: 2 }, + CNY: { head: 'CHINESE YUAN', centsHead: 'FEN', decimals: 2 }, + EUR: { head: 'EUROS', centsHead: 'CENTS', decimals: 2 }, + GBP: { head: 'POUNDS STERLING', centsHead: 'PENCE', decimals: 2 }, + HKD: { head: 'HONG KONG DOLLARS', centsHead: 'CENTS', decimals: 2 }, + JPY: { head: 'JAPANESE YEN', centsHead: 'SEN', decimals: 0 }, + AUD: { head: 'AUSTRALIAN DOLLARS', centsHead: 'CENTS', decimals: 2 }, + CAD: { head: 'CANADIAN DOLLARS', centsHead: 'CENTS', decimals: 2 }, + SGD: { head: 'SINGAPORE DOLLARS', centsHead: 'CENTS', decimals: 2 }, + NZD: { head: 'NEW ZEALAND DOLLARS', centsHead: 'CENTS', decimals: 2 }, + CHF: { head: 'SWISS FRANCS', centsHead: 'CENTIMES', decimals: 2 }, + KRW: { head: 'SOUTH KOREAN WON', centsHead: 'JEON', decimals: 0 }, + TWD: { head: 'NEW TAIWAN DOLLARS', centsHead: 'CENTS', decimals: 2 }, + MOP: { head: 'MACAO PATACAS', centsHead: 'AVOS', decimals: 2 }, + AED: { head: 'UAE DIRHAMS', centsHead: 'FILS', decimals: 2 }, + SAR: { head: 'SAUDI RIYALS', centsHead: 'HALALAS', decimals: 2 }, + MYR: { head: 'MALAYSIAN RINGGIT', centsHead: 'SEN', decimals: 2 }, + THB: { head: 'THAI BAHT', centsHead: 'SATANG', decimals: 2 }, + VND: { head: 'VIETNAMESE DONG', centsHead: 'XU', decimals: 0 }, + IDR: { head: 'INDONESIAN RUPIAH', centsHead: 'SEN', decimals: 0 }, + PHP: { head: 'PHILIPPINE PESOS', centsHead: 'CENTAVOS', decimals: 2 }, + INR: { head: 'INDIAN RUPEES', centsHead: 'PAISE', decimals: 2 }, + RUB: { head: 'RUSSIAN RUBLES', centsHead: 'KOPECKS', decimals: 2 }, + BRL: { head: 'BRAZILIAN REALS', centsHead: 'CENTAVOS', decimals: 2 }, + MXN: { head: 'MEXICAN PESOS', centsHead: 'CENTAVOS', decimals: 2 }, + ZAR: { head: 'SOUTH AFRICAN RAND', centsHead: 'CENTS', decimals: 2 }, + TRY: { head: 'TURKISH LIRA', centsHead: 'KURUS', decimals: 2 }, + PLN: { head: 'POLISH ZLOTYS', centsHead: 'GROSZY', decimals: 2 }, + SEK: { head: 'SWEDISH KRONOR', centsHead: 'ORE', decimals: 2 }, + NOK: { head: 'NORWEGIAN KRONER', centsHead: 'ORE', decimals: 2 }, + DKK: { head: 'DANISH KRONER', centsHead: 'ORE', decimals: 2 }, + CZK: { head: 'CZECH KORUNAS', centsHead: 'HALERU', decimals: 2 }, + HUF: { head: 'HUNGARIAN FORINTS', centsHead: 'FILLER', decimals: 2 }, + EGP: { head: 'EGYPTIAN POUNDS', centsHead: 'PIASTRES', decimals: 2 } +} + +/** 1–999 转英文。百位后若有非零余数,按英式写法补 AND。 */ +function belowThousand(n) { + const parts = [] + const hundreds = Math.floor(n / 100) + const rest = n % 100 + if (hundreds) parts.push(`${ONES[hundreds]} HUNDRED`) + if (rest) { + if (hundreds) parts.push('AND') + if (rest < 20) parts.push(ONES[rest]) + else { + const ten = Math.floor(rest / 10) + const one = rest % 10 + parts.push(one ? `${TENS[ten]}-${ONES[one]}` : TENS[ten]) + } + } + return parts.join(' ') +} + +/** 非负整数转英文,按千分位分组。 */ +function integerToWords(value) { + if (!Number.isFinite(value) || value <= 0) return 'ZERO' + const groups = [] + let rest = Math.floor(value) + let scale = 0 + while (rest > 0) { + const group = rest % 1000 + if (group) { + const words = belowThousand(group) + groups.unshift(SCALES[scale] ? `${words} ${SCALES[scale]}` : words) + } + rest = Math.floor(rest / 1000) + scale += 1 + } + return groups.join(' ') +} + +/** 取币种配置;没登记的币种按 2 位小数 + 代码本身当货币名处理。 */ +export function currencyWordsMeta(code) { + const key = String(code || 'USD').toUpperCase() + return CURRENCY_WORDS[key] || { head: key, centsHead: 'CENTS', decimals: 2 } +} + +/** + * 金额转英文大写。 + * @param {number|string} amount 金额 + * @param {string} currency 币种代码 + * @returns {string} 形如 "SAY US DOLLARS ... ONLY";输入无法识别时返回空串 + */ +export function amountToWords(amount, currency = 'USD') { + const num = typeof amount === 'number' ? amount : Number(String(amount ?? '').replace(/[,\s]/g, '')) + if (!Number.isFinite(num)) return '' + + const meta = currencyWordsMeta(currency) + const factor = Math.pow(10, meta.decimals) + /* 先四舍五入到「最小单位」,避免 255.36 这类浮点误差把小数位算成 35.999… */ + const total = Math.round(Math.abs(num) * factor) + const major = Math.floor(total / factor) + const minor = total % factor + + const parts = [`SAY ${meta.head}`] + if (major > 0 || minor === 0) parts.push(integerToWords(major)) + if (minor > 0) { + // 有辅币时,主币为 0 也要读作 ZERO(单证上不会省略) + if (major === 0) parts.push('ZERO') + parts.push('AND') + parts.push(integerToWords(minor)) + parts.push(meta.centsHead) + } + parts.push('ONLY') + + /* 负数在贸易单证里没有位置,但真出现了也别把符号吞掉 */ + return (num < 0 ? 'MINUS ' : '') + parts.join(' ') +} diff --git a/frontend/src/auth.jsx b/frontend/src/auth.jsx new file mode 100644 index 0000000..42b5198 --- /dev/null +++ b/frontend/src/auth.jsx @@ -0,0 +1,103 @@ +import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' +import axios from 'axios' + +/** + * 会话上下文:全局唯一的「当前用户是谁」答案来源。 + * + * 为什么不把用户信息塞进 localStorage: + * 改了个人资料 / 换了角色之后,前端必须立刻拿到新值。所以只依赖后端的会话 cookie, + * 启动时打一次 /api/auth/me,之后由各处的 setUser / refresh 回写。 + */ +const AuthContext = createContext(null) + +export function AuthProvider({ children }) { + const [user, setUser] = useState(null) + const [loading, setLoading] = useState(true) + + /* 启动时确定登录态 */ + useEffect(() => { + let alive = true + ;(async () => { + try { + const { data } = await axios.get('/api/auth/me') + if (alive) setUser(data?.user || null) + } catch { + if (alive) setUser(null) + } finally { + if (alive) setLoading(false) + } + })() + return () => { alive = false } + }, []) + + /* 任何接口返回 401(会话过期 / 被踢),就地清空登录态,界面自然回登录页 */ + useEffect(() => { + const id = axios.interceptors.response.use( + response => response, + error => { + const status = error?.response?.status + const url = String(error?.config?.url || '') + const isAuthProbe = url.includes('/api/auth/login') || url.includes('/api/auth/me') + if (status === 401 && !isAuthProbe) setUser(null) + return Promise.reject(error) + } + ) + return () => axios.interceptors.response.eject(id) + }, []) + + const refresh = useCallback(async () => { + try { + const { data } = await axios.get('/api/auth/me') + setUser(data?.user || null) + return data?.user || null + } catch { + setUser(null) + return null + } + }, []) + + const login = useCallback(async (username, password) => { + const { data } = await axios.post('/api/auth/login', { username, password }) + setUser(data.user) + return data.user + }, []) + + const logout = useCallback(async () => { + try { + await axios.post('/api/auth/logout') + } catch { /* 会话可能已过期,忽略 */ } + setUser(null) + }, []) + + const value = useMemo( + () => ({ user, loading, login, logout, refresh, setUser }), + [user, loading, login, logout, refresh] + ) + return {children} +} + +export function useAuth() { + const context = useContext(AuthContext) + if (!context) throw new Error('useAuth 必须在 AuthProvider 内使用') + return context +} + +/** 权限判断:系统管理员兜底全通过,其余按角色所属权限组的权限点判断 */ +export function hasPermission(user, key) { + if (!user) return false + if (user.role === 'admin') return true + return Array.isArray(user.permissions) && user.permissions.includes(key) +} + +/** 角色英文值 → 中文标签 */ +export const ROLE_LABELS = { + admin: '系统管理员', + chairman: '董事长', + general_manager: '总经理', + sales_manager: '销售经理', + sales_rep: '销售业务员' +} + +export function roleLabel(role) { + return ROLE_LABELS[role] || role || '—' +} diff --git a/frontend/src/branding.js b/frontend/src/branding.js new file mode 100644 index 0000000..77e3e5e --- /dev/null +++ b/frontend/src/branding.js @@ -0,0 +1,73 @@ +import { useEffect, useState } from 'react' +import axios from 'axios' + +/** + * 系统标识与公司名 —— 全局唯一来源。 + * + * 口径(Wayne 2026-09-15 定): + * - **系统名固定不可改**:`name` / `subtitle` 恒为后端给的固定署名, + * 登录页、顶栏、启动页、浏览器标题全用它,也**不跟随组织名**(引流用)。 + * - **公司名(组织名)可改**:只在设置页与组织下拉里出现,不影响界面牌子。 + * + * 用法: + * 组件里 const brand = useBranding() ← brand.name / brand.subtitle 是只读的 + * 设置页标题 setPageTitle('用户管理') ← 页面别自己拼 document.title + */ + +const FALLBACK = { + name: 'happy出口通', + subtitle: 'Ai Export System', + system_locked: true, + org_name: '', + org_id: null +} + +let state = { ...FALLBACK } +let currentPage = null +let loading = null +const listeners = new Set() + +function emit() { + for (const listener of listeners) { + try { listener(state) } catch { /* 某个订阅者出错不该拖垮其他订阅者 */ } + } +} + +/** 页面标题统一在这里拼:品牌名变了,标题里的旧名也会跟着更新 */ +export function setPageTitle(page) { + currentPage = page || null + document.title = currentPage ? `${currentPage} · ${state.name}` : state.name +} + +export function getBranding() { + return state +} + +/** 保存成功后立刻套用,不用等下次刷新 */ +export function applyBranding(patch) { + state = { ...state, ...(patch || {}) } + setPageTitle(currentPage) + emit() + return state +} + +/** 启动时拉一次;失败就用兜底值(后端挂了也不该白屏) */ +export function loadBranding() { + if (loading) return loading + loading = axios + .get('/api/system/branding') + .then(({ data }) => applyBranding(data)) + .catch(() => state) + return loading +} + +export function useBranding() { + const [value, setValue] = useState(state) + useEffect(() => { + setValue(state) + listeners.add(setValue) + loadBranding() + return () => listeners.delete(setValue) + }, []) + return value +} diff --git a/frontend/src/components/AIAssistant.jsx b/frontend/src/components/AIAssistant.jsx index cd54e84..0e8c2f4 100644 --- a/frontend/src/components/AIAssistant.jsx +++ b/frontend/src/components/AIAssistant.jsx @@ -1,9 +1,9 @@ import React, { useEffect, useMemo, useRef, useState } from 'react' import axios from 'axios' -import { BotIcon, CloseIcon, MinusIcon, SendIcon, SparkIcon } from './Icons' +import { AIBotIcon, CloseIcon, MinusIcon, SendIcon, UserIcon } from './Icons' const POS_KEY = 'happy.aiPos' -const WIN_WIDTH = 348 +const WIN_WIDTH = 440 const starterMessages = [ { role: 'assistant', text: '我是小皮,你的外贸业务助理。可以帮你查客户、产品、供应商、订单、商机和待办,也能代你发起系统内的操作。' }, @@ -135,22 +135,27 @@ function AIAssistant({ isOpen, onClose }) { onDoubleClick={() => setMinimized(value => !value)} >
- -
[COPILOT]

小皮

+ +
+ BUSINESS COPILOT +

小皮

+ 你的外贸业务助理 +
- {!minimized && } + {!minimized && 在线} -
@@ -159,11 +164,11 @@ function AIAssistant({ isOpen, onClose }) {
已连接系统业务层可读取模块数据,敏感写操作会先请求确认
-
我能帮你
{capabilities.map(capability =>
✓{capability}
)}
+
我能帮你
{capabilities.map(capability =>
✓{capability}
)}
{context &&
当前数据快照{context.stats?.customers || 0} 客户{context.stats?.products || 0} 产品{context.stats?.orders || 0} 订单
}
- {messages.map((message, index) =>
{message.role === 'assistant' ? : 'W'}

{message.text}

{message.meta && {message.meta}}
)} - {loading &&
} + {messages.map((message, index) =>
{message.role === 'assistant' ? : }

{message.text}

{message.meta && {message.meta}}
)} + {loading &&
}
@@ -177,3 +182,4 @@ function AIAssistant({ isOpen, onClose }) { } export default AIAssistant + diff --git a/frontend/src/components/AIConfigSettings.jsx b/frontend/src/components/AIConfigSettings.jsx new file mode 100644 index 0000000..f736ed6 --- /dev/null +++ b/frontend/src/components/AIConfigSettings.jsx @@ -0,0 +1,721 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import axios from 'axios' +import { + SparkIcon, PlusIcon, RefreshIcon, CheckIcon, CloseIcon, SaveIcon, SendIcon +} from './Icons' + +/** + * 智能配置 —— 多模型接入与用途分配。 + * + * 交互主线(Wayne 2026-09-15 定的四步): + * 1. 选一个常用接口预设(自动填地址/兼容模式/推荐模型) + * 2. 填密钥 → 保存(保存后自动跑一次连通性测试) + * 3. 「获取模型」→ 勾选这个接入要用哪些模型 + * 4. 到「按用途分配」把不同活派给不同模型(可以同时接多家) + * + * 所有配置都在后端,前端不做任何本地持久化 —— 换台电脑登录照样是这套配置。 + */ + +const VALUE_SEP = '::' +const encodeChoice = (providerId, model) => (providerId && model ? `${providerId}${VALUE_SEP}${model}` : '') +const decodeChoice = (value) => { + if (!value) return null + const index = value.indexOf(VALUE_SEP) + if (index < 0) return null + return { provider_id: value.slice(0, index), model: value.slice(index + VALUE_SEP.length) } +} + +function fmtTime(value) { + if (!value) return '' + try { + return new Date(value).toLocaleString('zh-CN', { hour12: false }) + } catch { + return String(value) + } +} + +/** + * Base URL 的即时体检。 + * + * 两种兼容模式的地址写法**不一样**,这里拦最常见的两类填错: + * ① 把完整接口路径也填了进来(/chat/completions、/messages)—— 系统还会再拼一次; + * ② Anthropic 协议漏了 /v1 —— 各家 SDK 文档里的 base_url 是写到 /anthropic 为止的 + * (SDK 自己补 /v1),本系统不补,直接照抄必然 404。 + */ +function baseUrlAdvice(mode, value) { + const url = String(value || '').trim() + if (!url) return null + const tail = url.replace(/\/+$/, '') + if (/\/(chat\/completions|completions)$/i.test(tail)) { + return { level: 'error', text: '不要带 /chat/completions —— 系统会自动拼上它,填到服务根路径就行。' } + } + if (/\/messages$/i.test(tail)) { + return { level: 'error', text: '不要带 /messages —— 系统会自动拼上它,填到服务根路径就行。' } + } + if (mode === 'anthropic' && !/\/v1$/i.test(tail)) { + return { + level: 'warn', + text: 'Anthropic 协议通常要写到 /v1 结尾(如 …/anthropic/v1)—— 系统是在这一层的后面拼 /messages,' + + '漏掉 /v1 多半会 404。' + } + } + return null +} + +function AIConfigSettings() { + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [notice, setNotice] = useState('') + const [config, setConfig] = useState({ + presets: [], modes: [], purposes: [], providers: [], assignments: {}, overview: {} + }) + const [editor, setEditor] = useState(null) + const [busy, setBusy] = useState('') + const [modelPanel, setModelPanel] = useState(null) + const [draft, setDraft] = useState({}) + const [probe, setProbe] = useState({ prompt: '', reply: '', error: '', busy: false }) + + const load = useCallback(async () => { + setLoading(true) + try { + const { data } = await axios.get('/api/ai/config') + setConfig(data) + const next = {} + for (const [purpose, value] of Object.entries(data.assignments || {})) { + next[purpose] = encodeChoice(value.provider_id, value.model) + } + setDraft(next) + setError('') + } catch (err) { + setError(err?.response?.data?.error || '读取智能配置失败') + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { load() }, [load]) + + const flash = (text) => { + setNotice(text) + window.setTimeout(() => setNotice(''), 3500) + } + + /* 所有启用中的接入 × 它们的模型,供用途分配下拉用 */ + const choices = useMemo(() => (config.providers || []) + .filter(provider => provider.enabled) + .map(provider => ({ + provider, + models: provider.models || [], + label: `${provider.name}${provider.has_key ? '' : '(未填密钥)'}` + })), [config.providers]) + + const assignedCount = useMemo( + () => Object.values(draft).filter(Boolean).length, + [draft] + ) + + /* ---------------------------------------------------------------- 接入 */ + + const openCreate = (presetKey = '') => { + const preset = (config.presets || []).find(item => item.key === presetKey) || null + setEditor({ + mode: 'create', + form: { + preset: preset?.key || 'openai', + name: preset?.label || '', + base_url: preset?.base_url || '', + mode: preset?.mode || 'openai', + api_key: '', + note: '', + enabled: true + }, + modelsText: (preset?.models || []).join('\n') + }) + } + + const openEdit = (provider) => { + setEditor({ + mode: 'edit', + id: provider.id, + form: { + preset: provider.preset, + name: provider.name, + base_url: provider.base_url, + mode: provider.mode, + api_key: '', + note: provider.note || '', + enabled: provider.enabled + }, + keyMasked: provider.key_masked, + modelsText: (provider.models || []).join('\n') + }) + } + + const changePreset = (presetKey) => { + const preset = (config.presets || []).find(item => item.key === presetKey) + if (!preset) return + setEditor(current => ({ + ...current, + form: { + ...current.form, + preset: preset.key, + name: preset.label, + base_url: preset.base_url, + mode: preset.mode + }, + modelsText: (preset.models || []).join('\n') + })) + } + + const saveProvider = async (event) => { + event.preventDefault() + if (!editor) return + setBusy('save-provider') + try { + const payload = { + ...editor.form, + models: editor.modelsText.split('\n').map(line => line.trim()).filter(Boolean) + } + if (editor.mode === 'edit' && !payload.api_key) delete payload.api_key + const { data } = editor.mode === 'edit' + ? await axios.patch(`/api/ai/providers/${editor.id}`, payload) + : await axios.post('/api/ai/providers', payload) + setEditor(null) + await load() + flash(`「${data.provider.name}」已保存`) + /* 保存完顺手测一次:用户填完 key 最想知道的就是「通不通」 */ + await runTest(data.provider.id, { silent: true }) + } catch (err) { + setError(err?.response?.data?.error || '保存模型接入失败') + } finally { + setBusy('') + } + } + + const runTest = async (id, { silent } = {}) => { + setBusy(`test:${id}`) + try { + const { data } = await axios.post(`/api/ai/providers/${id}/test`) + if (!silent) { + flash(data.result.ok + ? `连通正常(${data.result.latency_ms}ms):${data.result.detail}` + : `连通失败:${data.result.detail}`) + } + setConfig(current => ({ + ...current, + providers: current.providers.map(item => (item.id === id ? data.provider : item)) + })) + return data.result + } catch (err) { + const text = err?.response?.data?.error || '测试失败' + if (!silent) setError(text) + return { ok: false, detail: text } + } finally { + setBusy('') + } + } + + const fetchModels = async (provider) => { + setBusy(`models:${provider.id}`) + try { + const { data } = await axios.post(`/api/ai/providers/${provider.id}/models`) + /* 勾选默认值:优先沿用之前存过的模型;一个都没对上(换了服务商/换了模型名)就全选, + 否则用户会看到一个「获取成功但一个都没勾」的空面板,还得手工点一遍。 */ + const previous = (provider.models || []).filter(model => data.models.includes(model)) + setModelPanel({ + providerId: provider.id, + providerName: provider.name, + detail: data.detail, + source: data.source, + selected: previous.length ? previous : data.models, + models: data.models + }) + } catch (err) { + setError(err?.response?.data?.error || '获取模型清单失败') + } finally { + setBusy('') + } + } + + const saveModels = async () => { + if (!modelPanel) return + setBusy('save-models') + try { + await axios.patch(`/api/ai/providers/${modelPanel.providerId}`, { models: modelPanel.selected }) + setModelPanel(null) + await load() + flash('可用模型已更新') + } catch (err) { + setError(err?.response?.data?.error || '保存模型清单失败') + } finally { + setBusy('') + } + } + + const toggleProvider = async (provider) => { + setBusy(`toggle:${provider.id}`) + try { + await axios.patch(`/api/ai/providers/${provider.id}`, { enabled: !provider.enabled }) + await load() + flash(`「${provider.name}」已${provider.enabled ? '停用' : '启用'}`) + } catch (err) { + setError(err?.response?.data?.error || '操作失败') + } finally { + setBusy('') + } + } + + const removeProvider = async (provider) => { + if (!window.confirm(`移除模型接入「${provider.name}」?\n\n指向它的用途分配会一起清掉。`)) return + setBusy(`remove:${provider.id}`) + try { + await axios.delete(`/api/ai/providers/${provider.id}`) + await load() + flash(`「${provider.name}」已移除`) + } catch (err) { + setError(err?.response?.data?.error || '移除失败') + } finally { + setBusy('') + } + } + + /* ---------------------------------------------------------------- 分配 */ + + const saveAssignments = async () => { + setBusy('assignments') + try { + const assignments = {} + for (const [purpose, value] of Object.entries(draft)) { + const choice = decodeChoice(value) + if (choice) assignments[purpose] = choice + } + const { data } = await axios.put('/api/ai/assignments', { assignments }) + setConfig(current => ({ ...current, assignments: data.assignments, overview: data.overview })) + flash('用途分配已保存') + } catch (err) { + setError(err?.response?.data?.error || '保存分配失败') + } finally { + setBusy('') + } + } + + const runProbe = async () => { + const prompt = probe.prompt.trim() + if (!prompt) return + setProbe(current => ({ ...current, busy: true, reply: '', error: '' })) + try { + const { data } = await axios.post('/api/ai/chat', { prompt, purpose: 'assistant_chat' }) + setProbe(current => ({ ...current, busy: false, reply: data.text || '(模型返回空内容)' })) + } catch (err) { + setProbe(current => ({ + ...current, + busy: false, + error: err?.response?.data?.error || '试跑失败' + })) + } + } + + /* ---------------------------------------------------------------- 渲染 */ + + if (loading) { + return ( +
+

智能配置

+

正在读取模型接入配置…

+
+ ) + } + + const { overview = {} } = config + + /* 编辑弹窗里跟着「接口兼容模式」走派生值:示例、后缀说明、填错提示 */ + const activeMode = editor + ? (config.modes || []).find(item => item.value === editor.form.mode) || null + : null + const baseAdvice = editor ? baseUrlAdvice(editor.form.mode, editor.form.base_url) : null + + return ( +
+

智能配置

+

+ 接入一家或多家大模型服务,再把不同用途派给不同模型 —— 合同解析用长文本强的, + 图片识别用多模态的,日常对话用便宜的。全部配置存在本机数据库,换电脑登录即生效。 +

+ +
+
{overview.provider_count || 0}已接入
+
{overview.healthy_count || 0}测试通过
+
{overview.assigned_purposes?.length || 0}/{config.purposes.length}用途已分配
+
{overview.last_test_at ? fmtTime(overview.last_test_at).slice(5, 16) : '—'}最近测试
+
+ + {notice &&

{notice}

} + {error &&

{error}

} + + {/* ---------------- 模型接入 ---------------- */} +
+
+

模型接入

+
+ + +
+
+ + {config.providers.length === 0 + ?

+ 还没有接入任何模型服务。可以从下面的常用预设开始(DeepSeek / 通义 / Kimi / Claude / 本地 Ollama 等), + 也可以完全自定义。不接也能用系统 —— 只是「小皮」会退回到本地规则直答。 +

+ : ( +
    + {config.providers.map(provider => { + const test = provider.last_test + return ( +
  • +
    +
    + {provider.name} + {provider.preset_label} + {provider.mode === 'anthropic' ? 'Anthropic 协议' : 'OpenAI 兼容'} + {!provider.enabled && 已停用} +
    +
    + {provider.base_url} + 密钥 {provider.has_key ? provider.key_masked : '(未填写,本地服务可留空)'} + {provider.models.length} 个可用模型 +
    + {test && ( +
    + {test.ok ? : } + {test.detail} + {fmtTime(test.at)} · {test.latency_ms}ms +
    + )} +
    +
    + + + + + +
    + + {modelPanel?.providerId === provider.id && ( +
    +
    + 可用模型({modelPanel.models.length}) + {modelPanel.detail}{modelPanel.source === 'preset' ? ' · 预设推荐' : ''} +
    +
    + {modelPanel.models.map(model => { + const checked = modelPanel.selected.includes(model) + return ( + + ) + })} +
    +
    + + +
    +
    + )} +
  • + ) + })} +
+ )} +
+ + {/* ---------------- 用途分配 ---------------- */} +
+
+

按用途分配模型

+
+ 已分配 {assignedCount}/{config.purposes.length} + +
+
+ + {config.providers.length === 0 + ?

先在上面接入至少一家模型服务,这里就能选了。

+ : ( + + + + + + {config.purposes.map(purpose => ( + + + + + + + ))} + +
用途说明使用的模型
+ {purpose.label} + {purpose.vision && 需多模态} + {purpose.hint} + + + {draft[purpose.key] && ( + + )} +
+ )} +
+ + {/* ---------------- 试跑 ---------------- */} +
+

验证一下

+

+ 用「智能助手对话」分配到的模型发一句话,确认配置真的通了。未分配时会提示先去做分配。 +

+
+ setProbe(current => ({ ...current, prompt: event.target.value }))} + onKeyDown={event => { if (event.key === 'Enter') runProbe() }} + /> + +
+ {probe.reply &&
{probe.reply}
} + {probe.error &&

{probe.error}

} +
+ +

+ 密钥以明文保存在本机数据库文件里,请勿把 data/trade.db 外发; + 对外接口只会返回打码后的密钥。 +

+ + {/* ---------------- 新增 / 编辑弹窗 ---------------- */} + {editor && ( +
!busy && setEditor(null)}> +
event.stopPropagation()}> +
+

{editor.mode === 'edit' ? `编辑接入「${editor.form.name}」` : '新增模型接入'}

+ +
+
+
+ + + {(() => { + const preset = config.presets.find(item => item.key === editor.form.preset) + if (!preset?.note) return null + return ( +

+ {preset.note} + {preset.docs && ( + <> + {' · '} + 去申请密钥 + + )} +

+ ) + })()} +
+ +
+
+ + setEditor(current => ({ ...current, form: { ...current.form, name: event.target.value } }))} + placeholder="例如 DeepSeek 生产" + /> +
+
+ + +
+
+ +
+ + setEditor(current => ({ ...current, form: { ...current.form, base_url: event.target.value } }))} + placeholder={activeMode?.base_placeholder || 'https://api.example.com/v1'} + /> + {activeMode?.base_hint &&

{activeMode.base_hint}

} + {baseAdvice && ( +

{baseAdvice.text}

+ )} + {activeMode?.base_examples?.length > 0 && ( +
+ {activeMode.label}常见地址(点一下填入): + {activeMode.base_examples.map(item => ( + + ))} +
+ )} +
+ +
+ + setEditor(current => ({ ...current, form: { ...current.form, api_key: event.target.value } }))} + placeholder={editor.mode === 'edit' && editor.keyMasked + ? `已保存:${editor.keyMasked}(留空表示不修改)` + : 'sk-...'} + /> +
+ +
+ +