feat: 物料导入模板生成与 xlsx 解析
This commit is contained in:
parent
84e7f4d7d0
commit
4960a88497
21
go.mod
21
go.mod
|
|
@ -3,37 +3,42 @@ module github.com/wmms/wmms
|
||||||
go 1.21
|
go 1.21
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/DATA-DOG/go-sqlmock v1.5.2
|
||||||
github.com/gin-gonic/gin v1.9.1
|
github.com/gin-gonic/gin v1.9.1
|
||||||
|
github.com/go-playground/validator/v10 v10.14.0
|
||||||
github.com/go-sql-driver/mysql v1.7.1
|
github.com/go-sql-driver/mysql v1.7.1
|
||||||
github.com/gorilla/websocket v1.5.0
|
github.com/gorilla/websocket v1.5.0
|
||||||
golang.org/x/crypto v0.9.0
|
github.com/shopspring/decimal v1.4.0
|
||||||
|
github.com/xuri/excelize/v2 v2.8.1
|
||||||
|
golang.org/x/crypto v0.19.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/DATA-DOG/go-sqlmock v1.5.2 // indirect
|
|
||||||
github.com/bytedance/sonic v1.9.1 // indirect
|
github.com/bytedance/sonic v1.9.1 // indirect
|
||||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
||||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.14.0 // indirect
|
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
||||||
github.com/leodido/go-urn v1.2.4 // indirect
|
github.com/leodido/go-urn v1.2.4 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
|
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
|
||||||
github.com/shopspring/decimal v1.4.0 // indirect
|
github.com/richardlehane/mscfb v1.0.4 // indirect
|
||||||
|
github.com/richardlehane/msoleps v1.0.3 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||||
|
github.com/xuri/efp v0.0.0-20231025114914-d1ff6096ae53 // indirect
|
||||||
|
github.com/xuri/nfp v0.0.0-20230919160717-d98342af3f05 // indirect
|
||||||
golang.org/x/arch v0.3.0 // indirect
|
golang.org/x/arch v0.3.0 // indirect
|
||||||
golang.org/x/net v0.10.0 // indirect
|
golang.org/x/net v0.21.0 // indirect
|
||||||
golang.org/x/sys v0.8.0 // indirect
|
golang.org/x/sys v0.17.0 // indirect
|
||||||
golang.org/x/text v0.9.0 // indirect
|
golang.org/x/text v0.14.0 // indirect
|
||||||
google.golang.org/protobuf v1.30.0 // indirect
|
google.golang.org/protobuf v1.30.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|
|
||||||
36
go.sum
36
go.sum
|
|
@ -27,8 +27,6 @@ github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrt
|
||||||
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
|
||||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
|
@ -50,10 +48,17 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
|
||||||
|
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
|
||||||
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
|
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
|
||||||
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
|
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
|
||||||
|
github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
|
||||||
|
github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||||
|
github.com/richardlehane/msoleps v1.0.3 h1:aznSZzrwYRl3rLKRT3gUk9am7T/mLNSnJINvN0AQoVM=
|
||||||
|
github.com/richardlehane/msoleps v1.0.3/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
|
@ -65,25 +70,34 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
|
|
||||||
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||||
|
github.com/xuri/efp v0.0.0-20231025114914-d1ff6096ae53 h1:Chd9DkqERQQuHpXjR/HSV1jLZA6uaoiwwH3vSuF3IW0=
|
||||||
|
github.com/xuri/efp v0.0.0-20231025114914-d1ff6096ae53/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
|
||||||
|
github.com/xuri/excelize/v2 v2.8.1 h1:pZLMEwK8ep+CLIUWpWmvW8IWE/yxqG0I1xcN6cVMGuQ=
|
||||||
|
github.com/xuri/excelize/v2 v2.8.1/go.mod h1:oli1E4C3Pa5RXg1TBXn4ENCXDV5JUMlBluUhG7c+CEE=
|
||||||
|
github.com/xuri/nfp v0.0.0-20230919160717-d98342af3f05 h1:qhbILQo1K3mphbwKh1vNm4oGezE1eF9fQWmNiIpSfI4=
|
||||||
|
github.com/xuri/nfp v0.0.0-20230919160717-d98342af3f05/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
||||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||||
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
|
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
|
||||||
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||||
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
|
golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo=
|
||||||
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
|
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||||
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
|
golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4=
|
||||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
|
||||||
|
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
|
||||||
|
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||||
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
|
golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y=
|
||||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
|
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
|
|
|
||||||
196
internal/service/material_import_excel.go
Normal file
196
internal/service/material_import_excel.go
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/wmms/wmms/internal/model"
|
||||||
|
"github.com/xuri/excelize/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// MaterialImportSheetData 是填写数据的表名,「物料数据」。
|
||||||
|
MaterialImportSheetData = "物料数据"
|
||||||
|
// MaterialImportSheetGuide 是填写说明的表名。
|
||||||
|
MaterialImportSheetGuide = "填写说明"
|
||||||
|
// MaterialImportMaxRows 是数据区最大行数(不含表头)。
|
||||||
|
MaterialImportMaxRows = 20000
|
||||||
|
// MaterialImportMaxBytes 是上传文件大小上限。
|
||||||
|
MaterialImportMaxBytes = 5 << 20
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrImportTemplateInvalid 表示上传的文件不是本功能的模板。
|
||||||
|
ErrImportTemplateInvalid = errors.New("模板格式不正确,请重新下载最新模板")
|
||||||
|
// ErrImportTooManyRows 表示数据行数超出上限。
|
||||||
|
ErrImportTooManyRows = fmt.Errorf("导入行数超过 %d 行上限", MaterialImportMaxRows)
|
||||||
|
)
|
||||||
|
|
||||||
|
// materialImportHeaders 是生成模板时的列顺序(含选填列)。
|
||||||
|
var materialImportHeaders = []string{"物料名称", "规格", "单位", "大类", "小类"}
|
||||||
|
|
||||||
|
// materialImportRequiredHeaders 是表头里必须存在的列;缺任何一列都判定为
|
||||||
|
// 模板不合法。规格和单位是选填,用户删掉这两个空列是合理操作,不拦。
|
||||||
|
var materialImportRequiredHeaders = []string{"物料名称", "大类", "小类"}
|
||||||
|
|
||||||
|
// BuildMaterialImportTemplate 生成导入模板:一张填写说明表 + 一张空白数据表。
|
||||||
|
func BuildMaterialImportTemplate() ([]byte, error) {
|
||||||
|
f := excelize.NewFile()
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
if err := f.SetSheetName("Sheet1", MaterialImportSheetData); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := f.NewSheet(MaterialImportSheetGuide); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// 数据表放在最前面,用户打开文件即停在填写页。
|
||||||
|
idx, err := f.GetSheetIndex(MaterialImportSheetData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
f.SetActiveSheet(idx)
|
||||||
|
|
||||||
|
guide := [][]string{
|
||||||
|
{"字段", "是否必填", "示例", "填写规则"},
|
||||||
|
{"物料名称", "必填", "内六角螺栓", "最长 200 字"},
|
||||||
|
{"规格", "选填", "M8x30", "最长 200 字"},
|
||||||
|
{"单位", "选填", "个", "填本公司已启用的计量单位名称或编码;不填默认「个」"},
|
||||||
|
{"大类", "必填", "机械结构类", "填本公司已启用的物料大类名称"},
|
||||||
|
{"小类", "必填", "标准件", "填该大类下已启用的小类名称,且该小类必须已配置编码规则"},
|
||||||
|
{"", "", "", ""},
|
||||||
|
{"说明", "", "", "1. 只需填写「物料数据」表,从第 2 行开始;2. 物料编码无需填写,由系统按小类编码规则自动生成;3. 任意一行校验不通过,整批都不会导入。"},
|
||||||
|
}
|
||||||
|
for r, row := range guide {
|
||||||
|
for c, value := range row {
|
||||||
|
cell, err := excelize.CoordinatesToCellName(c+1, r+1)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := f.SetCellValue(MaterialImportSheetGuide, cell, value); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range guide[0] {
|
||||||
|
cell, _ := excelize.CoordinatesToCellName(i+1, 1)
|
||||||
|
_ = f.SetCellStyle(MaterialImportSheetGuide, cell, cell, guideHeaderStyle(f))
|
||||||
|
}
|
||||||
|
_ = f.SetColWidth(MaterialImportSheetGuide, "A", "A", 14)
|
||||||
|
_ = f.SetColWidth(MaterialImportSheetGuide, "B", "D", 24)
|
||||||
|
|
||||||
|
for i, header := range materialImportHeaders {
|
||||||
|
cell, err := excelize.CoordinatesToCellName(i+1, 1)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := f.SetCellValue(MaterialImportSheetData, cell, header); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := f.SetCellStyle(MaterialImportSheetData, cell, cell, guideHeaderStyle(f)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = f.SetColWidth(MaterialImportSheetData, "A", "E", 18)
|
||||||
|
|
||||||
|
buf, err := f.WriteToBuffer()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// guideHeaderStyle 返回表头加粗样式;样式创建失败不影响模板可用,调用方忽略错误。
|
||||||
|
func guideHeaderStyle(f *excelize.File) int {
|
||||||
|
style, err := f.NewStyle(&excelize.Style{Font: &excelize.Font{Bold: true}})
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return style
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseMaterialImportFile 解析上传的 xlsx,返回带 Excel 行号的数据行。
|
||||||
|
// 列按表头名定位:列顺序可调、选填列可整列删掉、多余列忽略;只有缺少必填列
|
||||||
|
// 或缺少「物料数据」表时才判定模板不合法。
|
||||||
|
// 整行为空的行会被跳过;只要任一列有内容就保留,交给后续校验报错。
|
||||||
|
func ParseMaterialImportFile(r io.Reader) ([]model.MaterialImportRow, error) {
|
||||||
|
f, err := excelize.OpenReader(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrImportTemplateInvalid
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
idx, err := f.GetSheetIndex(MaterialImportSheetData)
|
||||||
|
if err != nil || idx < 0 {
|
||||||
|
return nil, ErrImportTemplateInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
all, err := f.GetRows(MaterialImportSheetData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(all) == 0 {
|
||||||
|
return nil, ErrImportTemplateInvalid
|
||||||
|
}
|
||||||
|
columns := materialImportColumnIndex(all[0])
|
||||||
|
for _, required := range materialImportRequiredHeaders {
|
||||||
|
if _, ok := columns[required]; !ok {
|
||||||
|
return nil, ErrImportTemplateInvalid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := make([]model.MaterialImportRow, 0, len(all)-1)
|
||||||
|
for i, raw := range all[1:] {
|
||||||
|
row := model.MaterialImportRow{
|
||||||
|
Row: i + 2, // Excel 行号:表头占第 1 行
|
||||||
|
Name: materialImportColumnValue(raw, columns, "物料名称"),
|
||||||
|
Spec: materialImportColumnValue(raw, columns, "规格"),
|
||||||
|
Unit: materialImportColumnValue(raw, columns, "单位"),
|
||||||
|
MajorName: materialImportColumnValue(raw, columns, "大类"),
|
||||||
|
MinorName: materialImportColumnValue(raw, columns, "小类"),
|
||||||
|
}
|
||||||
|
if row.Name == "" && row.Spec == "" && row.Unit == "" && row.MajorName == "" && row.MinorName == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rows = append(rows, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rows) > MaterialImportMaxRows {
|
||||||
|
return nil, ErrImportTooManyRows
|
||||||
|
}
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// materialImportColumnIndex 把表头行转成「列名 → 列号」。允许用户在表头里加
|
||||||
|
// 首尾空格、末尾加 * 标记必填;不认识的列忽略,重复列名取第一列。
|
||||||
|
func materialImportColumnIndex(header []string) map[string]int {
|
||||||
|
index := make(map[string]int, len(header))
|
||||||
|
for i, cell := range header {
|
||||||
|
name := strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(cell), "*"))
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := index[name]; !exists {
|
||||||
|
index[name] = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|
||||||
|
// materialImportColumnValue 按列名取值;选填列被整列删掉时返回空串。
|
||||||
|
func materialImportColumnValue(row []string, columns map[string]int, name string) string {
|
||||||
|
index, ok := columns[name]
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return materialImportCell(row, index)
|
||||||
|
}
|
||||||
|
|
||||||
|
func materialImportCell(row []string, index int) string {
|
||||||
|
if index >= len(row) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(row[index])
|
||||||
|
}
|
||||||
167
internal/service/material_import_excel_test.go
Normal file
167
internal/service/material_import_excel_test.go
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/xuri/excelize/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseMaterialImportFileReadsRowsWithExcelRowNumbers(t *testing.T) {
|
||||||
|
data := buildTestWorkbook(t, [][]string{
|
||||||
|
{"内六角螺栓", "M8x30", "个", "机械结构类", "标准件"},
|
||||||
|
{"", "", "", "", ""}, // 整行空,应被跳过
|
||||||
|
{"急停按钮", "XB2", "个", "电气元器件类", "成品件"},
|
||||||
|
})
|
||||||
|
|
||||||
|
rows, err := ParseMaterialImportFile(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) != 2 {
|
||||||
|
t.Fatalf("rows = %d, want 2", len(rows))
|
||||||
|
}
|
||||||
|
if rows[0].Row != 2 || rows[0].Name != "内六角螺栓" || rows[0].MinorName != "标准件" {
|
||||||
|
t.Fatalf("first row = %+v", rows[0])
|
||||||
|
}
|
||||||
|
// 第 3 行是空行被跳过,所以第二条数据是 Excel 第 4 行
|
||||||
|
if rows[1].Row != 4 || rows[1].Name != "急停按钮" {
|
||||||
|
t.Fatalf("second row = %+v", rows[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMaterialImportFileKeepsRowsWithAnyContent(t *testing.T) {
|
||||||
|
// 名称填了、其余为空:不是空行,必须保留交给校验去报错
|
||||||
|
data := buildTestWorkbook(t, [][]string{
|
||||||
|
{"只有名称", "", "", "", ""},
|
||||||
|
})
|
||||||
|
rows, err := ParseMaterialImportFile(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) != 1 || rows[0].Name != "只有名称" {
|
||||||
|
t.Fatalf("rows = %+v", rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMaterialImportFileRejectsMissingRequiredColumn(t *testing.T) {
|
||||||
|
// 只有「物料名称」和「规格」,缺必填的大类、小类
|
||||||
|
data := buildTestWorkbookWithHeaders(t, []string{"物料名称", "规格"}, [][]string{
|
||||||
|
{"内六角螺栓", "M8x30"},
|
||||||
|
})
|
||||||
|
|
||||||
|
_, err := ParseMaterialImportFile(bytes.NewReader(data))
|
||||||
|
if !errors.Is(err, ErrImportTemplateInvalid) {
|
||||||
|
t.Fatalf("err = %v, want ErrImportTemplateInvalid", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMaterialImportFileAcceptsMissingOptionalColumnsAndReorderedColumns(t *testing.T) {
|
||||||
|
// 删掉「规格」,并把「单位」挪到「物料名称」前面
|
||||||
|
headers := []string{"单位", "物料名称", "大类", "小类"}
|
||||||
|
data := buildTestWorkbookWithHeaders(t, headers, [][]string{
|
||||||
|
{"个", "内六角螺栓", "机械结构类", "标准件"},
|
||||||
|
})
|
||||||
|
|
||||||
|
rows, err := ParseMaterialImportFile(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) != 1 {
|
||||||
|
t.Fatalf("rows = %d, want 1", len(rows))
|
||||||
|
}
|
||||||
|
if rows[0].Name != "内六角螺栓" || rows[0].Unit != "个" || rows[0].MinorName != "标准件" {
|
||||||
|
t.Fatalf("row = %+v", rows[0])
|
||||||
|
}
|
||||||
|
if rows[0].Spec != "" {
|
||||||
|
t.Fatalf("缺失的规格列应解析为空,得到 %q", rows[0].Spec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMaterialImportFileIgnoresUnknownColumns(t *testing.T) {
|
||||||
|
headers := []string{"物料名称", "备注", "大类", "小类"}
|
||||||
|
data := buildTestWorkbookWithHeaders(t, headers, [][]string{
|
||||||
|
{"内六角螺栓", "与导入无关", "机械结构类", "标准件"},
|
||||||
|
})
|
||||||
|
|
||||||
|
rows, err := ParseMaterialImportFile(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) != 1 || rows[0].Name != "内六角螺栓" {
|
||||||
|
t.Fatalf("rows = %+v", rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMaterialImportFileRejectsTooManyRows(t *testing.T) {
|
||||||
|
rows := make([][]string, 0, MaterialImportMaxRows+1)
|
||||||
|
for i := 0; i < MaterialImportMaxRows+1; i++ {
|
||||||
|
rows = append(rows, []string{"物料", "", "", "机械结构类", "标准件"})
|
||||||
|
}
|
||||||
|
data := buildTestWorkbook(t, rows)
|
||||||
|
|
||||||
|
_, err := ParseMaterialImportFile(bytes.NewReader(data))
|
||||||
|
if !errors.Is(err, ErrImportTooManyRows) {
|
||||||
|
t.Fatalf("err = %v, want ErrImportTooManyRows", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildMaterialImportTemplateRoundTrips(t *testing.T) {
|
||||||
|
data, err := BuildMaterialImportTemplate()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build template: %v", err)
|
||||||
|
}
|
||||||
|
f, err := excelize.OpenReader(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open template: %v", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
if idx, _ := f.GetSheetIndex(MaterialImportSheetData); idx < 0 {
|
||||||
|
t.Fatal("模板缺少「物料数据」工作表")
|
||||||
|
}
|
||||||
|
if idx, _ := f.GetSheetIndex(MaterialImportSheetGuide); idx < 0 {
|
||||||
|
t.Fatal("模板缺少「填写说明」工作表")
|
||||||
|
}
|
||||||
|
headers, err := f.GetRows(MaterialImportSheetData)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read rows: %v", err)
|
||||||
|
}
|
||||||
|
if len(headers) == 0 || headers[0][0] != "物料名称" || headers[0][4] != "小类" {
|
||||||
|
t.Fatalf("表头 = %v", headers)
|
||||||
|
}
|
||||||
|
// 模板必须能被自己的解析器接受(表头一致)
|
||||||
|
if _, err := ParseMaterialImportFile(bytes.NewReader(data)); err != nil {
|
||||||
|
t.Fatalf("模板无法被解析器接受: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildTestWorkbook 用标准表头 + 给定数据行构造一个 xlsx 字节流。
|
||||||
|
func buildTestWorkbook(t *testing.T, dataRows [][]string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
return buildTestWorkbookWithHeaders(t, materialImportHeaders, dataRows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildTestWorkbookWithHeaders 允许指定表头,用来覆盖缺列、乱序、多余列的解析分支。
|
||||||
|
func buildTestWorkbookWithHeaders(t *testing.T, headers []string, dataRows [][]string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
f := excelize.NewFile()
|
||||||
|
defer f.Close()
|
||||||
|
f.SetSheetName("Sheet1", MaterialImportSheetData)
|
||||||
|
for i, header := range headers {
|
||||||
|
cell, _ := excelize.CoordinatesToCellName(i+1, 1)
|
||||||
|
f.SetCellValue(MaterialImportSheetData, cell, header)
|
||||||
|
}
|
||||||
|
for r, row := range dataRows {
|
||||||
|
for c, value := range row {
|
||||||
|
cell, _ := excelize.CoordinatesToCellName(c+1, r+2)
|
||||||
|
f.SetCellValue(MaterialImportSheetData, cell, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buf, err := f.WriteToBuffer()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write workbook: %v", err)
|
||||||
|
}
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user